From a8ed1f4b16a0be6879f4b212df0eae6890acba75 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Thu, 13 Aug 2020 14:33:27 -0700 Subject: [PATCH 01/51] [Reporting] Update more Server Types for TaskManager (#74915) * [Reporting] Update more Server Types for TaskManager * remove some task manager references * more strict * more strict 2 * simplify * fix test * fix test * routing validation unused types cleanup * remove more casting in route handlers * feedback changes * original comment was fine Co-authored-by: Elastic Machine --- x-pack/plugins/reporting/server/core.ts | 8 +- .../server/export_types/csv/create_job.ts | 4 +- .../export_types/csv/execute_job.test.ts | 112 +++---- .../server/export_types/csv/execute_job.ts | 4 +- .../export_types/csv/generate_csv/index.ts | 4 + .../server/export_types/csv/index.ts | 8 +- .../server/export_types/csv/types.d.ts | 6 +- .../csv_from_savedobject/execute_job.ts | 2 +- .../export_types/png/create_job/index.ts | 4 +- .../export_types/png/execute_job/index.ts | 4 +- .../server/export_types/png/index.ts | 8 +- .../server/export_types/png/types.d.ts | 7 +- .../printable_pdf/create_job/index.ts | 6 +- .../printable_pdf/execute_job/index.ts | 4 +- .../export_types/printable_pdf/index.ts | 8 +- .../export_types/printable_pdf/types.d.ts | 6 +- .../reporting/server/lib/create_worker.ts | 6 +- .../reporting/server/lib/enqueue_job.ts | 38 ++- .../server/lib/esqueue/constants/index.js | 4 +- .../reporting/server/lib/esqueue/worker.js | 48 +-- x-pack/plugins/reporting/server/lib/index.ts | 3 +- .../lib/{esqueue/constants => }/statuses.ts | 0 .../reporting/server/lib/store/mapping.ts | 2 +- .../reporting/server/lib/store/report.test.ts | 113 +++++-- .../reporting/server/lib/store/report.ts | 144 +++++++-- .../reporting/server/lib/store/store.test.ts | 304 ++++++++++++++---- .../reporting/server/lib/store/store.ts | 194 +++++++---- x-pack/plugins/reporting/server/plugin.ts | 10 +- .../server/routes/generate_from_jobparams.ts | 15 +- .../generate_from_savedobject_immediate.ts | 10 +- .../server/routes/generation.test.ts | 3 +- .../reporting/server/routes/generation.ts | 10 +- .../server/routes/lib/get_document_payload.ts | 7 +- .../routes/lib/get_job_params_from_request.ts | 16 +- .../reporting/server/routes/types.d.ts | 4 +- .../create_mock_reportingplugin.ts | 19 +- x-pack/plugins/reporting/server/types.ts | 44 +-- 37 files changed, 774 insertions(+), 415 deletions(-) rename x-pack/plugins/reporting/server/lib/{esqueue/constants => }/statuses.ts (100%) diff --git a/x-pack/plugins/reporting/server/core.ts b/x-pack/plugins/reporting/server/core.ts index 95dc7586ad4a6..25594e1c0140b 100644 --- a/x-pack/plugins/reporting/server/core.ts +++ b/x-pack/plugins/reporting/server/core.ts @@ -23,7 +23,6 @@ import { HeadlessChromiumDriverFactory } from './browsers/chromium/driver_factor import { screenshotsObservableFactory } from './lib/screenshots'; import { checkLicense, getExportTypesRegistry } from './lib'; import { ESQueueInstance } from './lib/create_queue'; -import { EnqueueJobFn } from './lib/enqueue_job'; import { ReportingStore } from './lib/store'; export interface ReportingInternalSetup { @@ -36,7 +35,6 @@ export interface ReportingInternalSetup { export interface ReportingInternalStart { browserDriverFactory: HeadlessChromiumDriverFactory; - enqueueJob: EnqueueJobFn; esqueue: ESQueueInstance; store: ReportingStore; savedObjects: SavedObjectsServiceStart; @@ -115,7 +113,7 @@ export class ReportingCore { /* * Gives async access to the startDeps */ - private async getPluginStartDeps() { + public async getPluginStartDeps() { if (this.pluginStartDeps) { return this.pluginStartDeps; } @@ -131,10 +129,6 @@ export class ReportingCore { return (await this.getPluginStartDeps()).esqueue; } - public async getEnqueueJob() { - return (await this.getPluginStartDeps()).enqueueJob; - } - public async getLicenseInfo() { const { licensing } = this.getPluginSetupDeps(); return await licensing.license$ diff --git a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts index 5e8ce923a79e0..252968e386b53 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/create_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/create_job.ts @@ -5,10 +5,10 @@ */ import { cryptoFactory } from '../../lib'; -import { ESQueueCreateJobFn, ScheduleTaskFnFactory } from '../../types'; +import { CreateJobFn, ScheduleTaskFnFactory } from '../../types'; import { JobParamsDiscoverCsv } from './types'; -export const scheduleTaskFnFactory: ScheduleTaskFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts index 75070c06824e2..5eeef0f9906dd 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.test.ts @@ -5,7 +5,7 @@ */ import nodeCrypto from '@elastic/node-crypto'; -import { IUiSettingsClient, ElasticsearchServiceSetup } from 'kibana/server'; +import { ElasticsearchServiceSetup, IUiSettingsClient } from 'kibana/server'; // @ts-ignore import Puid from 'puid'; import sinon from 'sinon'; @@ -20,8 +20,8 @@ import { CSV_BOM_CHARS } from '../../../common/constants'; import { LevelLogger } from '../../lib'; import { setFieldFormats } from '../../services'; import { createMockReportingCore } from '../../test_helpers'; -import { ScheduledTaskParamsCSV } from './types'; import { runTaskFnFactory } from './execute_job'; +import { ScheduledTaskParamsCSV } from './types'; const delay = (ms: number) => new Promise((resolve) => setTimeout(() => resolve(), ms)); @@ -125,7 +125,7 @@ describe('CSV Execute Job', function () { describe('basic Elasticsearch call behavior', function () { it('should decrypt encrypted headers and pass to callAsCurrentUser', async function () { - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); await runTask( 'job456', getScheduledTaskParams({ @@ -145,7 +145,7 @@ describe('CSV Execute Job', function () { testBody: true, }; - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const job = getScheduledTaskParams({ headers: encryptedHeaders, fields: [], @@ -172,7 +172,7 @@ describe('CSV Execute Job', function () { _scroll_id: scrollId, }); callAsCurrentUserStub.onSecondCall().resolves(defaultElasticsearchResponse); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); await runTask( 'job456', getScheduledTaskParams({ @@ -190,7 +190,7 @@ describe('CSV Execute Job', function () { }); it('should not execute scroll if there are no hits from the search', async function () { - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); await runTask( 'job456', getScheduledTaskParams({ @@ -224,7 +224,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); await runTask( 'job456', getScheduledTaskParams({ @@ -263,7 +263,7 @@ describe('CSV Execute Job', function () { _scroll_id: lastScrollId, }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); await runTask( 'job456', getScheduledTaskParams({ @@ -295,7 +295,7 @@ describe('CSV Execute Job', function () { _scroll_id: lastScrollId, }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -322,7 +322,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -347,7 +347,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['=SUM(A1:A2)', 'two'], @@ -373,7 +373,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -399,7 +399,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['=SUM(A1:A2)', 'two'], @@ -425,7 +425,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -452,7 +452,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -473,7 +473,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -496,7 +496,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -517,7 +517,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -533,7 +533,7 @@ describe('CSV Execute Job', function () { describe('Elasticsearch call errors', function () { it('should reject Promise if search call errors out', async function () { callAsCurrentUserStub.rejects(new Error()); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: [], @@ -552,7 +552,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); callAsCurrentUserStub.onSecondCall().rejects(new Error()); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: [], @@ -573,7 +573,7 @@ describe('CSV Execute Job', function () { _scroll_id: undefined, }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: [], @@ -592,7 +592,7 @@ describe('CSV Execute Job', function () { _scroll_id: undefined, }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: [], @@ -618,7 +618,7 @@ describe('CSV Execute Job', function () { _scroll_id: undefined, }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: [], @@ -644,7 +644,7 @@ describe('CSV Execute Job', function () { _scroll_id: undefined, }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: [], @@ -678,7 +678,7 @@ describe('CSV Execute Job', function () { }); it('should stop calling Elasticsearch when cancellationToken.cancel is called', async function () { - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); runTask( 'job345', getScheduledTaskParams({ @@ -697,7 +697,7 @@ describe('CSV Execute Job', function () { }); it(`shouldn't call clearScroll if it never got a scrollId`, async function () { - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); runTask( 'job345', getScheduledTaskParams({ @@ -715,7 +715,7 @@ describe('CSV Execute Job', function () { }); it('should call clearScroll if it got a scrollId', async function () { - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); runTask( 'job345', getScheduledTaskParams({ @@ -737,7 +737,7 @@ describe('CSV Execute Job', function () { describe('csv content', function () { it('should write column headers to output, even if there are no results', async function () { - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -749,7 +749,7 @@ describe('CSV Execute Job', function () { it('should use custom uiSettings csv:separator for header', async function () { mockUiSettingsClient.get.withArgs(CSV_SEPARATOR_SETTING).returns(';'); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -761,7 +761,7 @@ describe('CSV Execute Job', function () { it('should escape column headers if uiSettings csv:quoteValues is true', async function () { mockUiSettingsClient.get.withArgs(CSV_QUOTE_VALUES_SETTING).returns(true); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -773,7 +773,7 @@ describe('CSV Execute Job', function () { it(`shouldn't escape column headers if uiSettings csv:quoteValues is false`, async function () { mockUiSettingsClient.get.withArgs(CSV_QUOTE_VALUES_SETTING).returns(false); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one and a half', 'two', 'three-and-four', 'five & six'], @@ -784,7 +784,7 @@ describe('CSV Execute Job', function () { }); it('should write column headers to output, when there are results', async function () { - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ one: '1', two: '2' }], @@ -798,13 +798,14 @@ describe('CSV Execute Job', function () { searchRequest: { index: null, body: null }, }); const { content } = await runTask('job123', jobParams, cancellationToken); - const lines = content.split('\n'); + expect(content).not.toBe(null); + const lines = content!.split('\n'); const headerLine = lines[0]; expect(headerLine).toBe('one,two'); }); it('should use comma separated values of non-nested fields from _source', async function () { - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -819,13 +820,14 @@ describe('CSV Execute Job', function () { searchRequest: { index: null, body: null }, }); const { content } = await runTask('job123', jobParams, cancellationToken); - const lines = content.split('\n'); + expect(content).not.toBe(null); + const lines = content!.split('\n'); const valuesLine = lines[1]; expect(valuesLine).toBe('foo,bar'); }); it('should concatenate the hits from multiple responses', async function () { - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -846,14 +848,15 @@ describe('CSV Execute Job', function () { searchRequest: { index: null, body: null }, }); const { content } = await runTask('job123', jobParams, cancellationToken); - const lines = content.split('\n'); + expect(content).not.toBe(null); + const lines = content!.split('\n'); expect(lines[1]).toBe('foo,bar'); expect(lines[2]).toBe('baz,qux'); }); it('should use field formatters to format fields', async function () { - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); callAsCurrentUserStub.onFirstCall().resolves({ hits: { hits: [{ _source: { one: 'foo', two: 'bar' } }], @@ -877,7 +880,8 @@ describe('CSV Execute Job', function () { }, }); const { content } = await runTask('job123', jobParams, cancellationToken); - const lines = content.split('\n'); + expect(content).not.toBe(null); + const lines = content!.split('\n'); expect(lines[1]).toBe('FOO,bar'); }); @@ -889,13 +893,13 @@ describe('CSV Execute Job', function () { // tests use these 'simple' characters to make the math easier describe('when only the headers exceed the maxSizeBytes', function () { - let content: string; - let maxSizeReached: boolean; + let content: string | null; + let maxSizeReached: boolean | undefined; beforeEach(async function () { configGetStub.withArgs('csv', 'maxSizeBytes').returns(1); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -919,13 +923,13 @@ describe('CSV Execute Job', function () { }); describe('when headers are equal to maxSizeBytes', function () { - let content: string; - let maxSizeReached: boolean; + let content: string | null; + let maxSizeReached: boolean | undefined; beforeEach(async function () { configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -949,8 +953,8 @@ describe('CSV Execute Job', function () { }); describe('when the data exceeds the maxSizeBytes', function () { - let content: string; - let maxSizeReached: boolean; + let content: string | null; + let maxSizeReached: boolean | undefined; beforeEach(async function () { configGetStub.withArgs('csv', 'maxSizeBytes').returns(9); @@ -962,7 +966,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -987,8 +991,8 @@ describe('CSV Execute Job', function () { }); describe('when headers and data equal the maxSizeBytes', function () { - let content: string; - let maxSizeReached: boolean; + let content: string | null; + let maxSizeReached: boolean | undefined; beforeEach(async function () { mockReportingCore.getUiSettingsServiceFactory = () => @@ -1002,7 +1006,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -1039,7 +1043,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -1065,7 +1069,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], @@ -1091,7 +1095,7 @@ describe('CSV Execute Job', function () { _scroll_id: 'scrollId', }); - const runTask = await runTaskFnFactory(mockReportingCore, mockLogger); + const runTask = runTaskFnFactory(mockReportingCore, mockLogger); const jobParams = getScheduledTaskParams({ headers: encryptedHeaders, fields: ['one', 'two'], diff --git a/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts index f0c41a6a49703..802f4a81777c5 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/execute_job.ts @@ -10,7 +10,7 @@ import Hapi from 'hapi'; import { KibanaRequest } from '../../../../../../src/core/server'; import { CONTENT_TYPE_CSV, CSV_JOB_TYPE } from '../../../common/constants'; import { cryptoFactory, LevelLogger } from '../../lib'; -import { ESQueueWorkerExecuteFn, RunTaskFnFactory } from '../../types'; +import { WorkerExecuteFn, RunTaskFnFactory } from '../../types'; import { ScheduledTaskParamsCSV } from './types'; import { createGenerateCsv } from './generate_csv'; @@ -54,7 +54,7 @@ const getRequest = async (headers: string | undefined, crypto: Crypto, logger: L } as Hapi.Request); }; -export const runTaskFnFactory: RunTaskFnFactory> = function executeJobFactoryFn(reporting, parentLogger) { const config = reporting.getConfig(); diff --git a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts index 8da27100ac31c..06aa2434afc3f 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/generate_csv/index.ts @@ -113,6 +113,10 @@ export function createGenerateCsv(logger: LevelLogger) { break; } + if (cancellationToken.isCancelled()) { + break; + } + const flattened = flattenHit(hit); const rows = formatCsvValues(flattened); const rowsHaveFormulas = diff --git a/x-pack/plugins/reporting/server/export_types/csv/index.ts b/x-pack/plugins/reporting/server/export_types/csv/index.ts index dffc874831dc2..4bca42e0661e5 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/index.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/index.ts @@ -13,17 +13,17 @@ import { LICENSE_TYPE_TRIAL, } from '../../../common/constants'; import { CSV_JOB_TYPE as jobType } from '../../../constants'; -import { ESQueueCreateJobFn, ESQueueWorkerExecuteFn, ExportTypeDefinition } from '../../types'; -import { metadata } from './metadata'; +import { CreateJobFn, WorkerExecuteFn, ExportTypeDefinition } from '../../types'; import { scheduleTaskFnFactory } from './create_job'; import { runTaskFnFactory } from './execute_job'; +import { metadata } from './metadata'; import { JobParamsDiscoverCsv, ScheduledTaskParamsCSV } from './types'; export const getExportType = (): ExportTypeDefinition< JobParamsDiscoverCsv, - ESQueueCreateJobFn, + CreateJobFn, ScheduledTaskParamsCSV, - ESQueueWorkerExecuteFn + WorkerExecuteFn > => ({ ...metadata, jobType, diff --git a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts index 9e86a5bb254a3..e0d09d04a3d3a 100644 --- a/x-pack/plugins/reporting/server/export_types/csv/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/csv/types.d.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ScheduledTaskParams } from '../../types'; +import { CreateJobBaseParams, ScheduledTaskParams } from '../../types'; export type RawValue = string | object | null | undefined; @@ -28,10 +28,8 @@ export interface IndexPatternSavedObject { }; } -export interface JobParamsDiscoverCsv { - browserTimezone: string; +export interface JobParamsDiscoverCsv extends CreateJobBaseParams { indexPatternId: string; - objectType: string; title: string; searchRequest: SearchRequest; fields: string[]; diff --git a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts index 0cc9ec16ed71b..ec7e0a21f0498 100644 --- a/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts +++ b/x-pack/plugins/reporting/server/export_types/csv_from_savedobject/execute_job.ts @@ -41,7 +41,7 @@ export const runTaskFnFactory: RunTaskFnFactory = function e // jobID is only for "queued" jobs // Use the jobID as a logging tag or "immediate" const { jobParams } = jobPayload; - const jobLogger = logger.clone([jobId === null ? 'immediate' : jobId]); + const jobLogger = logger.clone(['immediate']); const generateCsv = createGenerateCsv(jobLogger); const { panel, visType } = jobParams as JobParamsPanelCsv & { panel: SearchPanel }; diff --git a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts index 9227354520b6e..2252177e98085 100644 --- a/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/create_job/index.ts @@ -5,11 +5,11 @@ */ import { cryptoFactory } from '../../../lib'; -import { ESQueueCreateJobFn, ScheduleTaskFnFactory } from '../../../types'; +import { CreateJobFn, ScheduleTaskFnFactory } from '../../../types'; import { validateUrls } from '../../common'; import { JobParamsPNG } from '../types'; -export const scheduleTaskFnFactory: ScheduleTaskFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts index 9c7134736f4f6..35cd4139df413 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts @@ -8,7 +8,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PNG_JOB_TYPE } from '../../../../common/constants'; -import { ESQueueWorkerExecuteFn, RunTaskFnFactory, TaskRunResult } from '../../..//types'; +import { WorkerExecuteFn, RunTaskFnFactory, TaskRunResult } from '../../..//types'; import { decryptJobHeaders, getConditionalHeaders, @@ -18,7 +18,7 @@ import { import { generatePngObservableFactory } from '../lib/generate_png'; import { ScheduledTaskParamsPNG } from '../types'; -type QueuedPngExecutorFactory = RunTaskFnFactory>; +type QueuedPngExecutorFactory = RunTaskFnFactory>; export const runTaskFnFactory: QueuedPngExecutorFactory = function executeJobFactoryFn( reporting, diff --git a/x-pack/plugins/reporting/server/export_types/png/index.ts b/x-pack/plugins/reporting/server/export_types/png/index.ts index 25b4dbd60535b..c966dedb6b076 100644 --- a/x-pack/plugins/reporting/server/export_types/png/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/index.ts @@ -12,17 +12,17 @@ import { LICENSE_TYPE_TRIAL, PNG_JOB_TYPE as jobType, } from '../../../common/constants'; -import { ESQueueCreateJobFn, ESQueueWorkerExecuteFn, ExportTypeDefinition } from '../../types'; -import { metadata } from './metadata'; +import { CreateJobFn, WorkerExecuteFn, ExportTypeDefinition } from '../../types'; import { scheduleTaskFnFactory } from './create_job'; import { runTaskFnFactory } from './execute_job'; +import { metadata } from './metadata'; import { JobParamsPNG, ScheduledTaskParamsPNG } from './types'; export const getExportType = (): ExportTypeDefinition< JobParamsPNG, - ESQueueCreateJobFn, + CreateJobFn, ScheduledTaskParamsPNG, - ESQueueWorkerExecuteFn + WorkerExecuteFn > => ({ ...metadata, jobType, diff --git a/x-pack/plugins/reporting/server/export_types/png/types.d.ts b/x-pack/plugins/reporting/server/export_types/png/types.d.ts index 4c40f55f0f0d6..1ddee8419df30 100644 --- a/x-pack/plugins/reporting/server/export_types/png/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/png/types.d.ts @@ -4,16 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ScheduledTaskParams } from '../../../server/types'; +import { CreateJobBaseParams, ScheduledTaskParams } from '../../../server/types'; import { LayoutInstance, LayoutParams } from '../../lib/layouts'; // Job params: structure of incoming user request data -export interface JobParamsPNG { - objectType: string; +export interface JobParamsPNG extends CreateJobBaseParams { title: string; relativeUrl: string; - browserTimezone: string; - layout: LayoutInstance; } // Job payload: structure of stored job data provided by create_job diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts index 4540983129ebc..5de089a13bfa4 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/create_job/index.ts @@ -4,12 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { validateUrls } from '../../common'; import { cryptoFactory } from '../../../lib'; -import { ESQueueCreateJobFn, ScheduleTaskFnFactory } from '../../../types'; +import { CreateJobFn, ScheduleTaskFnFactory } from '../../../types'; +import { validateUrls } from '../../common'; import { JobParamsPDF } from '../types'; -export const scheduleTaskFnFactory: ScheduleTaskFnFactory> = function createJobFactoryFn(reporting) { const config = reporting.getConfig(); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts index eb15c0a71ca3f..5ace1c987adb5 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts @@ -8,7 +8,7 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; import { PDF_JOB_TYPE } from '../../../../common/constants'; -import { ESQueueWorkerExecuteFn, RunTaskFnFactory, TaskRunResult } from '../../../types'; +import { WorkerExecuteFn, RunTaskFnFactory, TaskRunResult } from '../../../types'; import { decryptJobHeaders, getConditionalHeaders, @@ -19,7 +19,7 @@ import { import { generatePdfObservableFactory } from '../lib/generate_pdf'; import { ScheduledTaskParamsPDF } from '../types'; -type QueuedPdfExecutorFactory = RunTaskFnFactory>; +type QueuedPdfExecutorFactory = RunTaskFnFactory>; export const runTaskFnFactory: QueuedPdfExecutorFactory = function executeJobFactoryFn( reporting, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts index e5115c243c697..7f21d36c4b72c 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/index.ts @@ -12,17 +12,17 @@ import { LICENSE_TYPE_TRIAL, PDF_JOB_TYPE as jobType, } from '../../../common/constants'; -import { ESQueueCreateJobFn, ESQueueWorkerExecuteFn, ExportTypeDefinition } from '../../types'; -import { metadata } from './metadata'; +import { CreateJobFn, WorkerExecuteFn, ExportTypeDefinition } from '../../types'; import { scheduleTaskFnFactory } from './create_job'; import { runTaskFnFactory } from './execute_job'; +import { metadata } from './metadata'; import { JobParamsPDF, ScheduledTaskParamsPDF } from './types'; export const getExportType = (): ExportTypeDefinition< JobParamsPDF, - ESQueueCreateJobFn, + CreateJobFn, ScheduledTaskParamsPDF, - ESQueueWorkerExecuteFn + WorkerExecuteFn > => ({ ...metadata, jobType, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts index cba0f41f07536..7830f87780c2e 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ScheduledTaskParams } from '../../../server/types'; +import { CreateJobBaseParams, ScheduledTaskParams } from '../../../server/types'; import { LayoutInstance, LayoutParams } from '../../lib/layouts'; // Job params: structure of incoming user request data, after being parsed from RISON -export interface JobParamsPDF { - objectType: string; // visualization, dashboard, etc. Used for job info & telemetry +export interface JobParamsPDF extends CreateJobBaseParams { title: string; relativeUrls: string[]; - browserTimezone: string; layout: LayoutInstance; } diff --git a/x-pack/plugins/reporting/server/lib/create_worker.ts b/x-pack/plugins/reporting/server/lib/create_worker.ts index 837be1f44a093..5b0f1ddb2f157 100644 --- a/x-pack/plugins/reporting/server/lib/create_worker.ts +++ b/x-pack/plugins/reporting/server/lib/create_worker.ts @@ -8,7 +8,7 @@ import { CancellationToken } from '../../common'; import { PLUGIN_ID } from '../../common/constants'; import { ReportingCore } from '../../server'; import { LevelLogger } from '../../server/lib'; -import { ESQueueWorkerExecuteFn, ExportTypeDefinition, JobSource } from '../../server/types'; +import { ExportTypeDefinition, JobSource, WorkerExecuteFn } from '../../server/types'; import { ESQueueInstance } from './create_queue'; // @ts-ignore untyped dependency import { events as esqueueEvents } from './esqueue'; @@ -22,10 +22,10 @@ export function createWorkerFactory(reporting: ReportingCore, log // Once more document types are added, this will need to be passed in return async function createWorker(queue: ESQueueInstance) { // export type / execute job map - const jobExecutors: Map> = new Map(); + const jobExecutors: Map> = new Map(); for (const exportType of reporting.getExportTypesRegistry().getAll() as Array< - ExportTypeDefinition> + ExportTypeDefinition> >) { const jobExecutor = exportType.runTaskFnFactory(reporting, logger); jobExecutors.set(exportType.jobType, jobExecutor); diff --git a/x-pack/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/plugins/reporting/server/lib/enqueue_job.ts index d1554a03b9389..31960c782b7b9 100644 --- a/x-pack/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/plugins/reporting/server/lib/enqueue_job.ts @@ -5,15 +5,15 @@ */ import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; +import { ReportingCore } from '../'; import { AuthenticatedUser } from '../../../security/server'; -import { ESQueueCreateJobFn } from '../../server/types'; -import { ReportingCore } from '../core'; +import { CreateJobBaseParams, CreateJobFn } from '../types'; import { LevelLogger } from './'; -import { ReportingStore, Report } from './store'; +import { Report } from './store'; export type EnqueueJobFn = ( exportTypeId: string, - jobParams: unknown, + jobParams: CreateJobBaseParams, user: AuthenticatedUser | null, context: RequestHandlerContext, request: KibanaRequest @@ -21,41 +21,39 @@ export type EnqueueJobFn = ( export function enqueueJobFactory( reporting: ReportingCore, - store: ReportingStore, parentLogger: LevelLogger ): EnqueueJobFn { - const config = reporting.getConfig(); - const queueTimeout = config.get('queue', 'timeout'); - const browserType = config.get('capture', 'browser', 'type'); - const maxAttempts = config.get('capture', 'maxAttempts'); const logger = parentLogger.clone(['queue-job']); return async function enqueueJob( exportTypeId: string, - jobParams: unknown, + jobParams: CreateJobBaseParams, user: AuthenticatedUser | null, context: RequestHandlerContext, request: KibanaRequest ) { - type ScheduleTaskFnType = ESQueueCreateJobFn; + type ScheduleTaskFnType = CreateJobFn; - const username = user ? user.username : false; + const username: string | null = user ? user.username : null; const exportType = reporting.getExportTypesRegistry().getById(exportTypeId); if (exportType == null) { throw new Error(`Export type ${exportTypeId} does not exist in the registry!`); } - const scheduleTask = exportType.scheduleTaskFnFactory(reporting, logger) as ScheduleTaskFnType; + const [scheduleTask, { store }] = await Promise.all([ + exportType.scheduleTaskFnFactory(reporting, logger) as ScheduleTaskFnType, + reporting.getPluginStartDeps(), + ]); + + // add encrytped headers const payload = await scheduleTask(jobParams, context, request); - const options = { - timeout: queueTimeout, - created_by: username, - browser_type: browserType, - max_attempts: maxAttempts, - }; + // store the pending report, puts it in the Reporting Management UI table + const report = await store.addReport(exportType.jobType, username, payload); + + logger.info(`Scheduled ${exportType.name} report: ${report._id}`); - return await store.addReport(exportType.jobType, payload, options); + return report; }; } diff --git a/x-pack/plugins/reporting/server/lib/esqueue/constants/index.js b/x-pack/plugins/reporting/server/lib/esqueue/constants/index.js index 5fcff3531851a..7f7383bb8611d 100644 --- a/x-pack/plugins/reporting/server/lib/esqueue/constants/index.js +++ b/x-pack/plugins/reporting/server/lib/esqueue/constants/index.js @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { events } from './events'; -import { statuses } from './statuses'; +import { statuses } from '../../statuses'; import { defaultSettings } from './default_settings'; +import { events } from './events'; export const constants = { ...events, diff --git a/x-pack/plugins/reporting/server/lib/esqueue/worker.js b/x-pack/plugins/reporting/server/lib/esqueue/worker.js index 469bafd694612..0c3a6384f6b9a 100644 --- a/x-pack/plugins/reporting/server/lib/esqueue/worker.js +++ b/x-pack/plugins/reporting/server/lib/esqueue/worker.js @@ -158,26 +158,18 @@ export class Worker extends events.EventEmitter { kibana_name: this.kibanaName, }; - return this.queue.store - .updateReport({ - index: job._index, - id: job._id, - if_seq_no: job._seq_no, - if_primary_term: job._primary_term, - body: { doc }, - }) - .then((response) => { - this.info(`Job marked as claimed: ${getUpdatedDocPath(response)}`); - const updatedJob = { - ...job, - ...response, - }; - updatedJob._source = { - ...job._source, - ...doc, - }; - return updatedJob; - }); + return this.queue.store.setReportClaimed(job, doc).then((response) => { + this.info(`Job marked as claimed: ${getUpdatedDocPath(response)}`); + const updatedJob = { + ...job, + ...response, + }; + updatedJob._source = { + ...job._source, + ...doc, + }; + return updatedJob; + }); } _failJob(job, output = false) { @@ -198,13 +190,7 @@ export class Worker extends events.EventEmitter { }); return this.queue.store - .updateReport({ - index: job._index, - id: job._id, - if_seq_no: job._seq_no, - if_primary_term: job._primary_term, - body: { doc }, - }) + .setReportFailed(job, doc) .then((response) => { this.info(`Job marked as failed: ${getUpdatedDocPath(response)}`); }) @@ -295,13 +281,7 @@ export class Worker extends events.EventEmitter { }; return this.queue.store - .updateReport({ - index: job._index, - id: job._id, - if_seq_no: job._seq_no, - if_primary_term: job._primary_term, - body: { doc }, - }) + .setReportCompleted(job, doc) .then((response) => { const eventOutput = { job: formatJobObject(job), diff --git a/x-pack/plugins/reporting/server/lib/index.ts b/x-pack/plugins/reporting/server/lib/index.ts index e4adb1188e3fc..f3a09cffbb104 100644 --- a/x-pack/plugins/reporting/server/lib/index.ts +++ b/x-pack/plugins/reporting/server/lib/index.ts @@ -8,8 +8,9 @@ export { checkLicense } from './check_license'; export { createQueueFactory } from './create_queue'; export { cryptoFactory } from './crypto'; export { enqueueJobFactory } from './enqueue_job'; -export { getExportTypesRegistry } from './export_types_registry'; +export { ExportTypesRegistry, getExportTypesRegistry } from './export_types_registry'; export { LevelLogger } from './level_logger'; +export { statuses } from './statuses'; export { ReportingStore } from './store'; export { startTrace } from './trace'; export { runValidations } from './validate'; diff --git a/x-pack/plugins/reporting/server/lib/esqueue/constants/statuses.ts b/x-pack/plugins/reporting/server/lib/statuses.ts similarity index 100% rename from x-pack/plugins/reporting/server/lib/esqueue/constants/statuses.ts rename to x-pack/plugins/reporting/server/lib/statuses.ts diff --git a/x-pack/plugins/reporting/server/lib/store/mapping.ts b/x-pack/plugins/reporting/server/lib/store/mapping.ts index a819923e2f105..d08b928cdca4b 100644 --- a/x-pack/plugins/reporting/server/lib/store/mapping.ts +++ b/x-pack/plugins/reporting/server/lib/store/mapping.ts @@ -45,7 +45,7 @@ export const mapping = { priority: { type: 'byte' }, timeout: { type: 'long' }, process_expiration: { type: 'date' }, - created_by: { type: 'keyword' }, + created_by: { type: 'keyword' }, // `null` if security is disabled created_at: { type: 'date' }, started_at: { type: 'date' }, completed_at: { type: 'date' }, diff --git a/x-pack/plugins/reporting/server/lib/store/report.test.ts b/x-pack/plugins/reporting/server/lib/store/report.test.ts index 83444494e61d3..9ac5d1f87c387 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.test.ts @@ -8,46 +8,59 @@ import { Report } from './report'; describe('Class Report', () => { it('constructs Report instance', () => { - const opts = { - index: '.reporting-test-index-12345', + const report = new Report({ + _index: '.reporting-test-index-12345', jobtype: 'test-report', created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', max_attempts: 50, - payload: { payload_test_field: 1 }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, timeout: 30000, priority: 1, - }; - const report = new Report(opts); - expect(report.toJSON()).toMatchObject({ - _primary_term: undefined, - _seq_no: undefined, + }); + + expect(report.toEsDocsJSON()).toMatchObject({ + _index: '.reporting-test-index-12345', + _source: { + attempts: 0, + browser_type: 'browser_type_test_string', + completed_at: undefined, + created_at: undefined, + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + meta: undefined, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, + priority: 1, + started_at: undefined, + status: 'pending', + timeout: 30000, + }, + }); + expect(report.toApiJSON()).toMatchObject({ browser_type: 'browser_type_test_string', created_by: 'created_by_test_string', jobtype: 'test-report', max_attempts: 50, - payload: { - payload_test_field: 1, - }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, priority: 1, timeout: 30000, }); - expect(report.id).toBeDefined(); + expect(report._id).toBeDefined(); }); - it('updateWithDoc method syncs takes fields to sync ES metadata', () => { - const opts = { - index: '.reporting-test-index-12345', + it('updateWithEsDoc method syncs fields to sync ES metadata', () => { + const report = new Report({ + _index: '.reporting-test-index-12345', jobtype: 'test-report', created_by: 'created_by_test_string', browser_type: 'browser_type_test_string', max_attempts: 50, - payload: { payload_test_field: 1 }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, timeout: 30000, priority: 1, - }; - const report = new Report(opts); + }); const metadata = { _index: '.reporting-test-update', @@ -55,23 +68,53 @@ describe('Class Report', () => { _primary_term: 77, _seq_no: 99, }; - report.updateWithDoc(metadata); - - expect(report.toJSON()).toMatchObject({ - index: '.reporting-test-update', - _primary_term: 77, - _seq_no: 99, - browser_type: 'browser_type_test_string', - created_by: 'created_by_test_string', - jobtype: 'test-report', - max_attempts: 50, - payload: { - payload_test_field: 1, - }, - priority: 1, - timeout: 30000, - }); + report.updateWithEsDoc(metadata); - expect(report._id).toBe('12342p9o387549o2345'); + expect(report.toEsDocsJSON()).toMatchInlineSnapshot(` + Object { + "_id": "12342p9o387549o2345", + "_index": ".reporting-test-update", + "_source": Object { + "attempts": 0, + "browser_type": "browser_type_test_string", + "completed_at": undefined, + "created_at": undefined, + "created_by": "created_by_test_string", + "jobtype": "test-report", + "max_attempts": 50, + "meta": undefined, + "payload": Object { + "headers": "payload_test_field", + "objectType": "testOt", + }, + "priority": 1, + "started_at": undefined, + "status": "pending", + "timeout": 30000, + }, + } + `); + expect(report.toApiJSON()).toMatchInlineSnapshot(` + Object { + "attempts": 0, + "browser_type": "browser_type_test_string", + "completed_at": undefined, + "created_at": undefined, + "created_by": "created_by_test_string", + "id": "12342p9o387549o2345", + "index": ".reporting-test-update", + "jobtype": "test-report", + "max_attempts": 50, + "meta": undefined, + "payload": Object { + "headers": "payload_test_field", + "objectType": "testOt", + }, + "priority": 1, + "started_at": undefined, + "status": "pending", + "timeout": 30000, + } + `); }); }); diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts index cc9967e64b6eb..5ff71ae7a7182 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -6,80 +6,158 @@ // @ts-ignore no module definition import Puid from 'puid'; +import { JobStatuses } from '../../../constants'; +import { LayoutInstance } from '../layouts'; -interface Payload { - id?: string; - index: string; +/* + * The document created by Reporting to store in the .reporting index + */ +interface ReportingDocument { + _id: string; + _index: string; + _seq_no: unknown; + _primary_term: unknown; jobtype: string; - created_by: string | boolean; - payload: unknown; + created_by: string | null; + payload: { + headers: string; // encrypted headers + objectType: string; + layout?: LayoutInstance; + }; + meta: unknown; browser_type: string; - priority: number; max_attempts: number; timeout: number; + + status: string; + attempts: number; + output?: unknown; + started_at?: string; + completed_at?: string; + created_at?: string; + priority?: number; + process_expiration?: string; } +/* + * The document created by Reporting to store as task parameters for Task + * Manager to reference the report in .reporting + */ const puid = new Puid(); -export class Report { - public readonly jobtype: string; - public readonly created_by: string | boolean; - public readonly payload: unknown; - public readonly browser_type: string; - public readonly id: string; +export class Report implements Partial { + public _index?: string; + public _id: string; + public _primary_term?: unknown; // set by ES + public _seq_no: unknown; // set by ES - public readonly priority: number; - // queue stuff, to be removed with Task Manager integration + public readonly jobtype: string; + public readonly created_at?: string; + public readonly created_by?: string | null; + public readonly payload: { + headers: string; // encrypted headers + objectType: string; + layout?: LayoutInstance; + }; + public readonly meta: unknown; public readonly max_attempts: number; - public readonly timeout: number; + public readonly browser_type?: string; - public _index: string; - public _id?: string; // set by ES - public _primary_term?: unknown; // set by ES - public _seq_no: unknown; // set by ES + public readonly status: string; + public readonly attempts: number; + public readonly output?: unknown; + public readonly started_at?: string; + public readonly completed_at?: string; + public readonly process_expiration?: string; + public readonly priority?: number; + public readonly timeout?: number; /* * Create an unsaved report */ - constructor(opts: Payload) { - this.jobtype = opts.jobtype; + constructor(opts: Partial) { + this._id = opts._id != null ? opts._id : puid.generate(); + this._index = opts._index; + this._primary_term = opts._primary_term; + this._seq_no = opts._seq_no; + + this.payload = opts.payload!; + this.jobtype = opts.jobtype!; + this.max_attempts = opts.max_attempts!; + this.attempts = opts.attempts || 0; + + this.process_expiration = opts.process_expiration; + this.timeout = opts.timeout; + + this.created_at = opts.created_at; this.created_by = opts.created_by; - this.payload = opts.payload; + this.meta = opts.meta; this.browser_type = opts.browser_type; this.priority = opts.priority; - this.max_attempts = opts.max_attempts; - this.timeout = opts.timeout; - this.id = puid.generate(); - this._index = opts.index; + this.status = opts.status || JobStatuses.PENDING; + this.output = opts.output || null; } /* * Update the report with "live" storage metadata */ - updateWithDoc(doc: Partial) { - if (doc._index) { - this._index = doc._index; // can not be undefined + updateWithEsDoc(doc: Partial) { + if (doc._index == null || doc._id == null) { + throw new Error(`Report object from ES has missing fields!`); } this._id = doc._id; + this._index = doc._index; this._primary_term = doc._primary_term; this._seq_no = doc._seq_no; } - toJSON() { + /* + * Data structure for writing to Elasticsearch index + */ + toEsDocsJSON() { + return { + _id: this._id, + _index: this._index, + _source: { + jobtype: this.jobtype, + created_at: this.created_at, + created_by: this.created_by, + payload: this.payload, + meta: this.meta, + timeout: this.timeout, + max_attempts: this.max_attempts, + priority: this.priority, + browser_type: this.browser_type, + status: this.status, + attempts: this.attempts, + started_at: this.started_at, + completed_at: this.completed_at, + }, + }; + } + + /* + * Data structure for API responses + */ + toApiJSON() { return { - id: this.id, + id: this._id, index: this._index, - _seq_no: this._seq_no, - _primary_term: this._primary_term, jobtype: this.jobtype, + created_at: this.created_at, created_by: this.created_by, payload: this.payload, + meta: this.meta, timeout: this.timeout, max_attempts: this.max_attempts, priority: this.priority, browser_type: this.browser_type, + status: this.status, + attempts: this.attempts, + started_at: this.started_at, + completed_at: this.completed_at, }; } } diff --git a/x-pack/plugins/reporting/server/lib/store/store.test.ts b/x-pack/plugins/reporting/server/lib/store/store.test.ts index 4868a1dfdd8f3..c66e2dd7742c4 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.test.ts @@ -5,11 +5,12 @@ */ import sinon from 'sinon'; +import { ElasticsearchServiceSetup } from 'src/core/server'; import { ReportingConfig, ReportingCore } from '../..'; import { createMockReportingCore } from '../../test_helpers'; import { createMockLevelLogger } from '../../test_helpers/create_mock_levellogger'; +import { Report } from './report'; import { ReportingStore } from './store'; -import { ElasticsearchServiceSetup } from 'src/core/server'; const getMockConfig = (mockConfigGet: sinon.SinonStub) => ({ get: mockConfigGet, @@ -31,11 +32,13 @@ describe('ReportingStore', () => { mockConfig = getMockConfig(mockConfigGet); mockCore = await createMockReportingCore(mockConfig); + callClusterStub.reset(); callClusterStub.withArgs('indices.exists').resolves({}); callClusterStub.withArgs('indices.create').resolves({}); - callClusterStub.withArgs('index').resolves({}); + callClusterStub.withArgs('index').resolves({ _id: 'stub-id', _index: 'stub-index' }); callClusterStub.withArgs('indices.refresh').resolves({}); callClusterStub.withArgs('update').resolves({}); + callClusterStub.withArgs('get').resolves({}); mockCore.getElasticsearchService = () => (mockElasticsearch as unknown) as ElasticsearchServiceSetup; @@ -45,25 +48,25 @@ describe('ReportingStore', () => { it('returns Report object', async () => { const store = new ReportingStore(mockCore, mockLogger); const reportType = 'unknowntype'; - const reportPayload = {}; - const reportOptions = { - timeout: 10000, - created_by: 'created_by_string', - browser_type: 'browser_type_string', - max_attempts: 1, + const reportPayload = { + browserTimezone: 'UTC', + headers: 'rp_headers_1', + objectType: 'testOt', }; - await expect( - store.addReport(reportType, reportPayload, reportOptions) - ).resolves.toMatchObject({ + await expect(store.addReport(reportType, 'username1', reportPayload)).resolves.toMatchObject({ _primary_term: undefined, _seq_no: undefined, - browser_type: 'browser_type_string', - created_by: 'created_by_string', + attempts: 0, + browser_type: undefined, + completed_at: undefined, + created_by: 'username1', jobtype: 'unknowntype', - max_attempts: 1, + max_attempts: undefined, payload: {}, priority: 10, - timeout: 10000, + started_at: undefined, + status: 'pending', + timeout: undefined, }); }); @@ -76,35 +79,31 @@ describe('ReportingStore', () => { const store = new ReportingStore(mockCore, mockLogger); const reportType = 'unknowntype'; - const reportPayload = {}; - const reportOptions = { - timeout: 10000, - created_by: 'created_by_string', - browser_type: 'browser_type_string', - max_attempts: 1, + const reportPayload = { + browserTimezone: 'UTC', + headers: 'rp_headers_2', + objectType: 'testOt', }; - expect( - store.addReport(reportType, reportPayload, reportOptions) - ).rejects.toMatchInlineSnapshot(`[Error: Invalid index interval: centurially]`); + expect(store.addReport(reportType, 'user1', reportPayload)).rejects.toMatchInlineSnapshot( + `[Error: Invalid index interval: centurially]` + ); }); it('handles error creating the index', async () => { // setup callClusterStub.withArgs('indices.exists').resolves(false); - callClusterStub.withArgs('indices.create').rejects(new Error('error')); + callClusterStub.withArgs('indices.create').rejects(new Error('horrible error')); const store = new ReportingStore(mockCore, mockLogger); const reportType = 'unknowntype'; - const reportPayload = {}; - const reportOptions = { - timeout: 10000, - created_by: 'created_by_string', - browser_type: 'browser_type_string', - max_attempts: 1, + const reportPayload = { + browserTimezone: 'UTC', + headers: 'rp_headers_3', + objectType: 'testOt', }; await expect( - store.addReport(reportType, reportPayload, reportOptions) - ).rejects.toMatchInlineSnapshot(`[Error: error]`); + store.addReport(reportType, 'user1', reportPayload) + ).rejects.toMatchInlineSnapshot(`[Error: horrible error]`); }); /* Creating the index will fail, if there were multiple jobs staged in @@ -116,20 +115,18 @@ describe('ReportingStore', () => { it('ignores index creation error if the index already exists and continues adding the report', async () => { // setup callClusterStub.withArgs('indices.exists').resolves(false); - callClusterStub.withArgs('indices.create').rejects(new Error('error')); + callClusterStub.withArgs('indices.create').rejects(new Error('devastating error')); const store = new ReportingStore(mockCore, mockLogger); const reportType = 'unknowntype'; - const reportPayload = {}; - const reportOptions = { - timeout: 10000, - created_by: 'created_by_string', - browser_type: 'browser_type_string', - max_attempts: 1, + const reportPayload = { + browserTimezone: 'UTC', + headers: 'rp_headers_4', + objectType: 'testOt', }; await expect( - store.addReport(reportType, reportPayload, reportOptions) - ).rejects.toMatchInlineSnapshot(`[Error: error]`); + store.addReport(reportType, 'user1', reportPayload) + ).rejects.toMatchInlineSnapshot(`[Error: devastating error]`); }); it('skips creating the index if already exists', async () => { @@ -141,26 +138,223 @@ describe('ReportingStore', () => { const store = new ReportingStore(mockCore, mockLogger); const reportType = 'unknowntype'; - const reportPayload = {}; - const reportOptions = { - timeout: 10000, - created_by: 'created_by_string', - browser_type: 'browser_type_string', - max_attempts: 1, + const reportPayload = { + browserTimezone: 'UTC', + headers: 'rp_headers_5', + objectType: 'testOt', }; - await expect( - store.addReport(reportType, reportPayload, reportOptions) - ).resolves.toMatchObject({ + await expect(store.addReport(reportType, 'user1', reportPayload)).resolves.toMatchObject({ + _primary_term: undefined, + _seq_no: undefined, + attempts: 0, + browser_type: undefined, + completed_at: undefined, + created_by: 'user1', + jobtype: 'unknowntype', + max_attempts: undefined, + payload: {}, + priority: 10, + started_at: undefined, + status: 'pending', + timeout: undefined, + }); + }); + + it('allows username string to be `null`', async () => { + // setup + callClusterStub.withArgs('indices.exists').resolves(false); + callClusterStub + .withArgs('indices.create') + .rejects(new Error('resource_already_exists_exception')); // will be triggered but ignored + + const store = new ReportingStore(mockCore, mockLogger); + const reportType = 'unknowntype'; + const reportPayload = { + browserTimezone: 'UTC', + headers: 'rp_test_headers', + objectType: 'testOt', + }; + await expect(store.addReport(reportType, null, reportPayload)).resolves.toMatchObject({ _primary_term: undefined, _seq_no: undefined, - browser_type: 'browser_type_string', - created_by: 'created_by_string', + attempts: 0, + browser_type: undefined, + completed_at: undefined, + created_by: null, jobtype: 'unknowntype', - max_attempts: 1, + max_attempts: undefined, payload: {}, priority: 10, - timeout: 10000, + started_at: undefined, + status: 'pending', + timeout: undefined, }); }); }); + + it('setReportClaimed sets the status of a record to processing', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const report = new Report({ + _id: 'id-of-processing', + _index: '.reporting-test-index-12345', + jobtype: 'test-report', + created_by: 'created_by_test_string', + browser_type: 'browser_type_test_string', + max_attempts: 50, + payload: { + headers: 'rp_test_headers', + objectType: 'testOt', + }, + timeout: 30000, + priority: 1, + }); + + await store.setReportClaimed(report, { testDoc: 'test' } as any); + + const updateCall = callClusterStub.getCalls().find((call) => call.args[0] === 'update'); + expect(updateCall && updateCall.args).toMatchInlineSnapshot(` + Array [ + "update", + Object { + "body": Object { + "doc": Object { + "status": "processing", + "testDoc": "test", + }, + }, + "id": "id-of-processing", + "if_primary_term": undefined, + "if_seq_no": undefined, + "index": ".reporting-test-index-12345", + }, + ] + `); + }); + + it('setReportFailed sets the status of a record to failed', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const report = new Report({ + _id: 'id-of-failure', + _index: '.reporting-test-index-12345', + jobtype: 'test-report', + created_by: 'created_by_test_string', + browser_type: 'browser_type_test_string', + max_attempts: 50, + payload: { + headers: 'rp_test_headers', + objectType: 'testOt', + }, + timeout: 30000, + priority: 1, + }); + + await store.setReportFailed(report, { errors: 'yes' } as any); + + const updateCall = callClusterStub.getCalls().find((call) => call.args[0] === 'update'); + expect(updateCall && updateCall.args).toMatchInlineSnapshot(` + Array [ + "update", + Object { + "body": Object { + "doc": Object { + "errors": "yes", + "status": "failed", + }, + }, + "id": "id-of-failure", + "if_primary_term": undefined, + "if_seq_no": undefined, + "index": ".reporting-test-index-12345", + }, + ] + `); + }); + + it('setReportCompleted sets the status of a record to completed', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const report = new Report({ + _id: 'vastly-great-report-id', + _index: '.reporting-test-index-12345', + jobtype: 'test-report', + created_by: 'created_by_test_string', + browser_type: 'browser_type_test_string', + max_attempts: 50, + payload: { + headers: 'rp_test_headers', + objectType: 'testOt', + }, + timeout: 30000, + priority: 1, + }); + + await store.setReportCompleted(report, { certainly_completed: 'yes' } as any); + + const updateCall = callClusterStub.getCalls().find((call) => call.args[0] === 'update'); + expect(updateCall && updateCall.args).toMatchInlineSnapshot(` + Array [ + "update", + Object { + "body": Object { + "doc": Object { + "certainly_completed": "yes", + "status": "completed", + }, + }, + "id": "vastly-great-report-id", + "if_primary_term": undefined, + "if_seq_no": undefined, + "index": ".reporting-test-index-12345", + }, + ] + `); + }); + + it('setReportCompleted sets the status of a record to completed_with_warnings', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const report = new Report({ + _id: 'vastly-great-report-id', + _index: '.reporting-test-index-12345', + jobtype: 'test-report', + created_by: 'created_by_test_string', + browser_type: 'browser_type_test_string', + max_attempts: 50, + payload: { + headers: 'rp_test_headers', + objectType: 'testOt', + }, + timeout: 30000, + priority: 1, + }); + + await store.setReportCompleted(report, { + certainly_completed: 'pretty_much', + output: { + warnings: [`those pants don't go with that shirt`], + }, + } as any); + + const updateCall = callClusterStub.getCalls().find((call) => call.args[0] === 'update'); + expect(updateCall && updateCall.args).toMatchInlineSnapshot(` + Array [ + "update", + Object { + "body": Object { + "doc": Object { + "certainly_completed": "pretty_much", + "output": Object { + "warnings": Array [ + "those pants don't go with that shirt", + ], + }, + "status": "completed_with_warnings", + }, + }, + "id": "vastly-great-report-id", + "if_primary_term": undefined, + "if_seq_no": undefined, + "index": ".reporting-test-index-12345", + }, + ] + `); + }); }); diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 0f1ed83b71767..12cff0e973ed6 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -5,36 +5,24 @@ */ import { ElasticsearchServiceSetup } from 'src/core/server'; -import { LevelLogger } from '../'; +import { LevelLogger, statuses } from '../'; import { ReportingCore } from '../../'; +import { CreateJobBaseParams, CreateJobBaseParamsEncryptedFields } from '../../types'; import { indexTimestamp } from './index_timestamp'; -import { LayoutInstance } from '../layouts'; import { mapping } from './mapping'; import { Report } from './report'; - -export const statuses = { - JOB_STATUS_PENDING: 'pending', - JOB_STATUS_PROCESSING: 'processing', - JOB_STATUS_COMPLETED: 'completed', - JOB_STATUS_WARNINGS: 'completed_with_warnings', - JOB_STATUS_FAILED: 'failed', - JOB_STATUS_CANCELLED: 'cancelled', -}; - -interface AddReportOpts { +interface JobSettings { timeout: number; - created_by: string | boolean; browser_type: string; max_attempts: number; + priority: number; } -interface UpdateQuery { - index: string; - id: string; - if_seq_no: unknown; - if_primary_term: unknown; - body: { doc: Partial }; -} +const checkReportIsEditable = (report: Report) => { + if (!report._id || !report._index) { + throw new Error(`Report object is not synced with ES!`); + } +}; /* * A class to give an interface to historical reports in the reporting.index @@ -43,9 +31,9 @@ interface UpdateQuery { * - interface for downloading the report */ export class ReportingStore { - public readonly indexPrefix: string; - public readonly indexInterval: string; - + private readonly indexPrefix: string; + private readonly indexInterval: string; + private readonly jobSettings: JobSettings; private client: ElasticsearchServiceSetup['legacy']['client']; private logger: LevelLogger; @@ -56,12 +44,18 @@ export class ReportingStore { this.client = elasticsearch.legacy.client; this.indexPrefix = config.get('index'); this.indexInterval = config.get('queue', 'indexInterval'); + this.jobSettings = { + timeout: config.get('queue', 'timeout'), + browser_type: config.get('capture', 'browser', 'type'), + max_attempts: config.get('capture', 'maxAttempts'), + priority: 10, // unused + }; this.logger = logger; } private async createIndex(indexName: string) { - return this.client + return await this.client .callAsInternalUser('indices.exists', { index: indexName, }) @@ -95,75 +89,157 @@ export class ReportingStore { return; } + this.logger.error(err); throw err; }); }); } - private async saveReport(report: Report) { - const payload = report.payload as { objectType: string; layout: LayoutInstance }; + /* + * Called from addReport, which handles any errors + */ + private async indexReport(report: Report) { + const params = report.payload; + + // Queing is handled by TM. These queueing-based fields for reference in Report Info panel + const infoFields = { + timeout: report.timeout, + process_expiration: new Date(0), // use epoch so the job query works + created_at: new Date(), + attempts: 0, + max_attempts: report.max_attempts, + status: statuses.JOB_STATUS_PENDING, + browser_type: report.browser_type, + }; const indexParams = { index: report._index, - id: report.id, + id: report._id, body: { + ...infoFields, jobtype: report.jobtype, meta: { // We are copying these values out of payload because these fields are indexed and can be aggregated on // for tracking stats, while payload contents are not. - objectType: payload.objectType, - layout: payload.layout ? payload.layout.id : 'none', + objectType: params.objectType, + layout: params.layout ? params.layout.id : 'none', }, payload: report.payload, created_by: report.created_by, - timeout: report.timeout, - process_expiration: new Date(0), // use epoch so the job query works - created_at: new Date(), - attempts: 0, - max_attempts: report.max_attempts, - status: statuses.JOB_STATUS_PENDING, - browser_type: report.browser_type, }, }; - return this.client.callAsInternalUser('index', indexParams); + return await this.client.callAsInternalUser('index', indexParams); } + /* + * Called from addReport, which handles any errors + */ private async refreshIndex(index: string) { - return this.client.callAsInternalUser('indices.refresh', { index }); + return await this.client.callAsInternalUser('indices.refresh', { index }); } - public async addReport(type: string, payload: unknown, options: AddReportOpts): Promise { + public async addReport( + type: string, + username: string | null, + payload: CreateJobBaseParams & CreateJobBaseParamsEncryptedFields + ): Promise { const timestamp = indexTimestamp(this.indexInterval); const index = `${this.indexPrefix}-${timestamp}`; await this.createIndex(index); const report = new Report({ - index, + _index: index, payload, jobtype: type, - created_by: options.created_by, - browser_type: options.browser_type, - max_attempts: options.max_attempts, - timeout: options.timeout, - priority: 10, // unused + created_by: username, + ...this.jobSettings, }); - const doc = await this.saveReport(report); - report.updateWithDoc(doc); + try { + const doc = await this.indexReport(report); + report.updateWithEsDoc(doc); - await this.refreshIndex(index); - this.logger.info(`Successfully queued pending job: ${report._index}/${report.id}`); + await this.refreshIndex(index); + this.logger.debug(`Successfully stored pending job: ${report._index}/${report._id}`); - return report; + return report; + } catch (err) { + this.logger.error(`Error in addReport!`); + this.logger.error(err); + throw err; + } } - public async updateReport(query: UpdateQuery): Promise { - return this.client.callAsInternalUser('update', { - index: query.index, - id: query.id, - if_seq_no: query.if_seq_no, - if_primary_term: query.if_primary_term, - body: { doc: query.body.doc }, - }); + public async setReportClaimed(report: Report, stats: Partial): Promise { + const doc = { + ...stats, + status: statuses.JOB_STATUS_PROCESSING, + }; + + try { + checkReportIsEditable(report); + + return await this.client.callAsInternalUser('update', { + id: report._id, + index: report._index, + if_seq_no: report._seq_no, + if_primary_term: report._primary_term, + body: { doc }, + }); + } catch (err) { + this.logger.error('Error in setting report processing status!'); + this.logger.error(err); + throw err; + } + } + + public async setReportFailed(report: Report, stats: Partial): Promise { + const doc = { + ...stats, + status: statuses.JOB_STATUS_FAILED, + }; + + try { + checkReportIsEditable(report); + + return await this.client.callAsInternalUser('update', { + id: report._id, + index: report._index, + if_seq_no: report._seq_no, + if_primary_term: report._primary_term, + body: { doc }, + }); + } catch (err) { + this.logger.error('Error in setting report failed status!'); + this.logger.error(err); + throw err; + } + } + + public async setReportCompleted(report: Report, stats: Partial): Promise { + try { + const { output } = stats as { output: any }; + const status = + output && output.warnings && output.warnings.length > 0 + ? statuses.JOB_STATUS_WARNINGS + : statuses.JOB_STATUS_COMPLETED; + const doc = { + ...stats, + status, + }; + checkReportIsEditable(report); + + return await this.client.callAsInternalUser('update', { + id: report._id, + index: report._index, + if_seq_no: report._seq_no, + if_primary_term: report._primary_term, + body: { doc }, + }); + } catch (err) { + this.logger.error('Error in setting report complete status!'); + this.logger.error(err); + throw err; + } } } diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index cedc9dc14a237..20e22c2db00e3 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -8,13 +8,7 @@ import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core import { ReportingCore } from './'; import { initializeBrowserDriverFactory } from './browsers'; import { buildConfig, ReportingConfigType } from './config'; -import { - createQueueFactory, - enqueueJobFactory, - LevelLogger, - runValidations, - ReportingStore, -} from './lib'; +import { createQueueFactory, LevelLogger, runValidations, ReportingStore } from './lib'; import { registerRoutes } from './routes'; import { setFieldFormats } from './services'; import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types'; @@ -94,14 +88,12 @@ export class ReportingPlugin const browserDriverFactory = await initializeBrowserDriverFactory(config, logger); const store = new ReportingStore(reportingCore, logger); const esqueue = await createQueueFactory(reportingCore, store, logger); // starts polling for pending jobs - const enqueueJob = enqueueJobFactory(reportingCore, store, logger); // called from generation routes reportingCore.pluginStart({ browserDriverFactory, savedObjects: core.savedObjects, uiSettings: core.uiSettings, esqueue, - enqueueJob, store, }); diff --git a/x-pack/plugins/reporting/server/routes/generate_from_jobparams.ts b/x-pack/plugins/reporting/server/routes/generate_from_jobparams.ts index 2a12a64d67a35..f4959b56dfea1 100644 --- a/x-pack/plugins/reporting/server/routes/generate_from_jobparams.ts +++ b/x-pack/plugins/reporting/server/routes/generate_from_jobparams.ts @@ -10,6 +10,7 @@ import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routi import { HandlerErrorFunction, HandlerFunction } from './types'; import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; +import { CreateJobBaseParams } from '../types'; const BASE_GENERATE = `${API_BASE_URL}/generate`; @@ -44,13 +45,13 @@ export function registerGenerateFromJobParams( }, }, userHandler(async (user, context, req, res) => { - let jobParamsRison: string | null; + let jobParamsRison: null | string = null; if (req.body) { - const { jobParams: jobParamsPayload } = req.body as { jobParams: string }; - jobParamsRison = jobParamsPayload; - } else { - const { jobParams: queryJobParams } = req.query as { jobParams: string }; + const { jobParams: jobParamsPayload } = req.body; + jobParamsRison = jobParamsPayload ? jobParamsPayload : null; + } else if (req.query?.jobParams) { + const { jobParams: queryJobParams } = req.query; if (queryJobParams) { jobParamsRison = queryJobParams; } else { @@ -65,11 +66,11 @@ export function registerGenerateFromJobParams( }); } - const { exportType } = req.params as { exportType: string }; + const { exportType } = req.params; let jobParams; try { - jobParams = rison.decode(jobParamsRison) as object | null; + jobParams = rison.decode(jobParamsRison) as CreateJobBaseParams | null; if (!jobParams) { return res.customError({ statusCode: 400, diff --git a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts index 8250ca462049b..a0a8f25de7fc4 100644 --- a/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts +++ b/x-pack/plugins/reporting/server/routes/generate_from_savedobject_immediate.ts @@ -5,16 +5,24 @@ */ import { schema } from '@kbn/config-schema'; +import { KibanaRequest } from 'src/core/server'; import { ReportingCore } from '../'; import { API_BASE_GENERATE_V1 } from '../../common/constants'; import { scheduleTaskFnFactory } from '../export_types/csv_from_savedobject/create_job'; import { runTaskFnFactory } from '../export_types/csv_from_savedobject/execute_job'; +import { JobParamsPostPayloadPanelCsv } from '../export_types/csv_from_savedobject/types'; import { LevelLogger as Logger } from '../lib'; import { TaskRunResult } from '../types'; import { authorizedUserPreRoutingFactory } from './lib/authorized_user_pre_routing'; import { getJobParamsFromRequest } from './lib/get_job_params_from_request'; import { HandlerErrorFunction } from './types'; +export type CsvFromSavedObjectRequest = KibanaRequest< + { savedObjectType: string; savedObjectId: string }, + unknown, + JobParamsPostPayloadPanelCsv +>; + /* * This function registers API Endpoints for immediate Reporting jobs. The API inputs are: * - saved object type and ID @@ -56,7 +64,7 @@ export function registerGenerateCsvFromSavedObjectImmediate( }), }, }, - userHandler(async (user, context, req, res) => { + userHandler(async (user, context, req: CsvFromSavedObjectRequest, res) => { const logger = parentLogger.clone(['savedobject-csv']); const jobParams = getJobParamsFromRequest(req, { isImmediate: true }); const scheduleTaskFn = scheduleTaskFnFactory(reporting, logger); diff --git a/x-pack/plugins/reporting/server/routes/generation.test.ts b/x-pack/plugins/reporting/server/routes/generation.test.ts index 87a696948ad84..cef4da9aabbd4 100644 --- a/x-pack/plugins/reporting/server/routes/generation.test.ts +++ b/x-pack/plugins/reporting/server/routes/generation.test.ts @@ -138,8 +138,7 @@ describe('POST /api/reporting/generate', () => { }); it('returns 500 if job handler throws an error', async () => { - // throw an error from enqueueJob - core.getEnqueueJob = jest.fn().mockRejectedValue('Sorry, this tests says no'); + callClusterStub.withArgs('index').rejects('silly'); registerJobGenerationRoutes(core, mockLogger); diff --git a/x-pack/plugins/reporting/server/routes/generation.ts b/x-pack/plugins/reporting/server/routes/generation.ts index 017e875931ae2..b2115076aada4 100644 --- a/x-pack/plugins/reporting/server/routes/generation.ts +++ b/x-pack/plugins/reporting/server/routes/generation.ts @@ -10,6 +10,7 @@ import { kibanaResponseFactory } from 'src/core/server'; import { ReportingCore } from '../'; import { API_BASE_URL } from '../../common/constants'; import { LevelLogger as Logger } from '../lib'; +import { enqueueJobFactory } from '../lib/enqueue_job'; import { registerGenerateFromJobParams } from './generate_from_jobparams'; import { registerGenerateCsvFromSavedObjectImmediate } from './generate_from_savedobject_immediate'; import { HandlerFunction } from './types'; @@ -43,11 +44,10 @@ export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Lo } try { - const enqueueJob = await reporting.getEnqueueJob(); - const job = await enqueueJob(exportTypeId, jobParams, user, context, req); + const enqueueJob = enqueueJobFactory(reporting, logger); + const report = await enqueueJob(exportTypeId, jobParams, user, context, req); // return the queue's job information - const jobJson = job.toJSON(); const downloadBaseUrl = getDownloadBaseUrl(reporting); return res.ok({ @@ -55,8 +55,8 @@ export function registerJobGenerationRoutes(reporting: ReportingCore, logger: Lo 'content-type': 'application/json', }, body: { - path: `${downloadBaseUrl}/${jobJson.id}`, - job: jobJson, + path: `${downloadBaseUrl}/${report._id}`, + job: report.toApiJSON(), }, }); } catch (err) { diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts index d384cbb878a0e..84a98d6d1f1d7 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -8,8 +8,7 @@ import contentDisposition from 'content-disposition'; import { get } from 'lodash'; import { CSV_JOB_TYPE } from '../../../common/constants'; -import { statuses } from '../../lib/esqueue/constants/statuses'; -import { ExportTypesRegistry } from '../../lib/export_types_registry'; +import { ExportTypesRegistry, statuses } from '../../lib'; import { ExportTypeDefinition, JobSource, TaskRunResult } from '../../types'; type ExportTypeType = ExportTypeDefinition; @@ -18,11 +17,11 @@ interface ErrorFromPayload { message: string; } -// A camelCase version of TaskRunResult +// interface of the API result interface Payload { statusCode: number; content: string | Buffer | ErrorFromPayload; - contentType: string; + contentType: string | null; headers: Record; } diff --git a/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts index e5c1f38241349..bfa15a4022a4d 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_job_params_from_request.ts @@ -4,21 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'src/core/server'; -import { - JobParamsPanelCsv, - JobParamsPostPayloadPanelCsv, -} from '../../export_types/csv_from_savedobject/types'; +import { JobParamsPanelCsv } from '../../export_types/csv_from_savedobject/types'; +import { CsvFromSavedObjectRequest } from '../generate_from_savedobject_immediate'; export function getJobParamsFromRequest( - request: KibanaRequest, + request: CsvFromSavedObjectRequest, opts: { isImmediate: boolean } ): JobParamsPanelCsv { - const { savedObjectType, savedObjectId } = request.params as { - savedObjectType: string; - savedObjectId: string; - }; - const { timerange, state } = request.body as JobParamsPostPayloadPanelCsv; + const { savedObjectType, savedObjectId } = request.params; + const { timerange, state } = request.body; const post = timerange || state ? { timerange, state } : undefined; diff --git a/x-pack/plugins/reporting/server/routes/types.d.ts b/x-pack/plugins/reporting/server/routes/types.d.ts index 607ce34ab9465..c92c9fb7eef74 100644 --- a/x-pack/plugins/reporting/server/routes/types.d.ts +++ b/x-pack/plugins/reporting/server/routes/types.d.ts @@ -6,12 +6,12 @@ import { KibanaRequest, KibanaResponseFactory, RequestHandlerContext } from 'src/core/server'; import { AuthenticatedUser } from '../../../security/server'; -import { ScheduledTaskParams } from '../types'; +import { CreateJobBaseParams, ScheduledTaskParams } from '../types'; export type HandlerFunction = ( user: AuthenticatedUser | null, exportType: string, - jobParams: object, + jobParams: CreateJobBaseParams, context: RequestHandlerContext, req: KibanaRequest, res: KibanaResponseFactory diff --git a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts index 95b06aa39f07e..c508ee6974ca0 100644 --- a/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/plugins/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -8,7 +8,6 @@ jest.mock('../routes'); jest.mock('../usage'); jest.mock('../browsers'); jest.mock('../lib/create_queue'); -jest.mock('../lib/enqueue_job'); jest.mock('../lib/validate'); import * as Rx from 'rxjs'; @@ -19,10 +18,9 @@ import { initializeBrowserDriverFactory, } from '../browsers'; import { ReportingInternalSetup, ReportingInternalStart } from '../core'; -import { ReportingStartDeps } from '../types'; import { ReportingStore } from '../lib'; +import { ReportingStartDeps } from '../types'; import { createMockLevelLogger } from './create_mock_levellogger'; -import { Report } from '../lib/store'; (initializeBrowserDriverFactory as jest.Mock< Promise @@ -30,10 +28,13 @@ import { Report } from '../lib/store'; (chromium as any).createDriverFactory.mockImplementation(() => ({})); -const createMockPluginSetup = (setupMock?: any): ReportingInternalSetup => { +const createMockPluginSetup = ( + mockReportingCore: ReportingCore, + setupMock?: any +): ReportingInternalSetup => { return { elasticsearch: setupMock.elasticsearch || { legacy: { client: {} } }, - basePath: setupMock.basePath, + basePath: setupMock.basePath || '/all-about-that-basepath', router: setupMock.router, security: setupMock.security, licensing: { license$: Rx.of({ isAvailable: true, isActive: true, type: 'basic' }) } as any, @@ -48,7 +49,6 @@ const createMockPluginStart = ( const store = new ReportingStore(mockReportingCore, logger); return { browserDriverFactory: startMock.browserDriverFactory, - enqueueJob: startMock.enqueueJob || jest.fn().mockResolvedValue(new Report({} as any)), esqueue: startMock.esqueue, savedObjects: startMock.savedObjects || { getScopedClient: jest.fn() }, uiSettings: startMock.uiSettings || { asScopedToClient: () => ({ get: jest.fn() }) }, @@ -72,15 +72,14 @@ export const createMockReportingCore = async ( setupDepsMock: ReportingInternalSetup | undefined = undefined, startDepsMock: ReportingInternalStart | undefined = undefined ) => { - if (!setupDepsMock) { - setupDepsMock = createMockPluginSetup({}); - } - const mockReportingCore = { getConfig: () => config, getElasticsearchService: () => setupDepsMock?.elasticsearch, } as ReportingCore; + if (!setupDepsMock) { + setupDepsMock = createMockPluginSetup(mockReportingCore, {}); + } if (!startDepsMock) { startDepsMock = createMockPluginStart(mockReportingCore, {}); } diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index ff597b53ea0b0..c9649cb6e558b 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -19,24 +19,12 @@ import { LevelLogger } from './lib'; import { LayoutInstance } from './lib/layouts'; /* - * Routing / API types + * Routing types */ -interface ListQuery { - page: string; - size: string; - ids?: string; // optional field forbids us from extending RequestQuery -} - -interface GenerateQuery { - jobParams: string; -} - -export type ReportingRequestQuery = ListQuery | GenerateQuery; - export interface ReportingRequestPre { management: { - jobTypes: any; + jobTypes: string[]; }; user: string; } @@ -54,12 +42,14 @@ export interface TimeRangeParams { max?: Date | string | number | null; } +// the "raw" data coming from the client, unencrypted export interface JobParamPostPayload { timerange?: TimeRangeParams; } +// the pre-processed, encrypted data ready for storage export interface ScheduledTaskParams { - headers?: string; // serialized encrypted headers + headers: string; // serialized encrypted headers jobParams: JobParamsType; title: string; type: string; @@ -77,10 +67,10 @@ export interface JobSource { } export interface TaskRunResult { - content_type: string; + content_type: string | null; content: string | null; - size: number; csv_contains_formulas?: boolean; + size: number; max_size_reached?: boolean; warnings?: string[]; } @@ -177,17 +167,29 @@ export type ReportingSetup = object; export type CaptureConfig = ReportingConfigType['capture']; export type ScrollConfig = ReportingConfigType['csv']['scroll']; -export type ESQueueCreateJobFn = ( +export interface CreateJobBaseParams { + browserTimezone: string; + layout?: LayoutInstance; // for screenshot type reports + objectType: string; +} + +export interface CreateJobBaseParamsEncryptedFields extends CreateJobBaseParams { + basePath?: string; // for screenshot type reports + headers: string; // encrypted headers +} + +export type CreateJobFn = ( jobParams: JobParamsType, context: RequestHandlerContext, request: KibanaRequest -) => Promise; +) => Promise; -export type ESQueueWorkerExecuteFn = ( +// rename me +export type WorkerExecuteFn = ( jobId: string, job: ScheduledTaskParamsType, cancellationToken: CancellationToken -) => Promise; +) => Promise; export type ScheduleTaskFnFactory = ( reporting: ReportingCore, From 875f7701c37b0737208fd51e70a9c60f74fea8a3 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Thu, 13 Aug 2020 16:45:16 -0500 Subject: [PATCH 02/51] Add public url to Workplace Search plugin (#74991) --- x-pack/plugins/enterprise_search/public/plugin.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/x-pack/plugins/enterprise_search/public/plugin.ts b/x-pack/plugins/enterprise_search/public/plugin.ts index 74263fb36a958..42ad7de93b00e 100644 --- a/x-pack/plugins/enterprise_search/public/plugin.ts +++ b/x-pack/plugins/enterprise_search/public/plugin.ts @@ -73,6 +73,8 @@ export class EnterpriseSearchPlugin implements Plugin { const { chrome } = coreStart; chrome.docTitle.change(WORKPLACE_SEARCH_PLUGIN.NAME); + await this.setPublicUrl(config, coreStart.http); + const { renderApp } = await import('./applications'); const { WorkplaceSearch } = await import('./applications/workplace_search'); From 1729091ddfa466ddf447c55ef90ddd8c0a87d9a4 Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Thu, 13 Aug 2020 17:50:10 -0400 Subject: [PATCH 03/51] [Resolver] Stale query string values are removed when resolver's component instance ID changes. (#74979) The app can show more than 1 Resolver at a time. Each instance has a unique ID called the `resolverComponentInstanceID`. When the user interacts with Resolver it will add values to the query string. The query string values will contain the `resolverComponentInstanceID`. This allows each Resolver to keep its state separate. When resolver unmounts it will remove any query string values related to it. If Resolver's `resolverComponentInstanceID` changes it should remove query string values related to the old instance ID. It does not. This PR fixes that. Note: I don't know if it was possible for this bug to actually happen. I can't make it happen, but depending on how Resolver is mounted by its consumers it *could* --- .../test_utilities/simulator/index.tsx | 38 +++++--- .../resolver/view/clickthrough.test.tsx | 10 +-- .../public/resolver/view/panel.test.tsx | 8 +- .../public/resolver/view/query_params.test.ts | 89 +++++++++++++++++++ .../view/resolver_without_providers.tsx | 9 +- .../resolver/view/use_query_string_keys.ts | 21 +++++ .../view/use_resolver_query_params.ts | 64 ++++--------- .../view/use_resolver_query_params_cleaner.ts | 53 +++++++++++ 8 files changed, 220 insertions(+), 72 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/view/use_query_string_keys.ts create mode 100644 x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params_cleaner.ts diff --git a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx index 355b53e374092..14cdc26c80f53 100644 --- a/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/test_utilities/simulator/index.tsx @@ -22,10 +22,6 @@ import { sideEffectSimulatorFactory } from '../../view/side_effect_simulator_fac * Test a Resolver instance using jest, enzyme, and a mock data layer. */ export class Simulator { - /** - * A string that uniquely identifies this Resolver instance among others mounted in the DOM. - */ - private readonly resolverComponentInstanceID: string; /** * The redux store, creating in the constructor using the `dataAccessLayer`. * This code subscribes to state transitions. @@ -69,7 +65,6 @@ export class Simulator { databaseDocumentID?: string; history?: HistoryPackageHistoryInterface; }) { - this.resolverComponentInstanceID = resolverComponentInstanceID; // create the spy middleware (for debugging tests) this.spyMiddleware = spyMiddlewareFactory(); @@ -98,7 +93,7 @@ export class Simulator { // Render Resolver via the `MockResolver` component, using `enzyme`. this.wrapper = mount( ({ // the query string has a key showing that the second child is selected - queryStringSelectedNode: simulator.queryStringValues().selectedNode, + search: simulator.historyLocationSearch, // the second child is rendered in the DOM, and shows up as selected selectedSecondChildNodeCount: simulator.selectedProcessNode(entityIDs.secondChild) .length, @@ -102,7 +103,9 @@ describe('Resolver, when analyzing a tree that has no ancestors and 2 children', })) ).toYieldEqualTo({ // Just the second child should be marked as selected in the query string - queryStringSelectedNode: [entityIDs.secondChild], + search: urlSearch(resolverComponentInstanceID, { + selectedEntityID: entityIDs.secondChild, + }), // The second child is rendered and has `[aria-selected]` selectedSecondChildNodeCount: 1, // The origin child is rendered and doesn't have `[aria-selected]` @@ -175,9 +178,6 @@ describe('Resolver, when analyzing a tree that has two related events for the or simulator.testSubject('resolver:map:node-submenu-item').map((node) => node.text()) ) ).toYieldEqualTo(['2 registry']); - await expect( - simulator.map(() => simulator.testSubject('resolver:map:node-submenu-item').length) - ).toYieldEqualTo(1); }); }); describe('and when the related events button is clicked again', () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx index 21b5a30ee9890..037337fb2f868 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.test.tsx @@ -152,9 +152,11 @@ describe(`Resolver: when analyzing a tree with no ancestors and two children, an ]); }); it("should have the first node's ID in the query string", async () => { - await expect(simulator().map(() => simulator().queryStringValues())).toYieldEqualTo({ - selectedNode: [entityIDs.origin], - }); + await expect(simulator().map(() => simulator().historyLocationSearch)).toYieldEqualTo( + urlSearch(resolverComponentInstanceID, { + selectedEntityID: entityIDs.origin, + }) + ); }); describe('and when the node list link has been clicked', () => { beforeEach(async () => { diff --git a/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts b/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts new file mode 100644 index 0000000000000..26c25cfab2c21 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/query_params.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { noAncestorsTwoChildren } from '../data_access_layer/mocks/no_ancestors_two_children'; +import { Simulator } from '../test_utilities/simulator'; +// Extend jest with a custom matcher +import '../test_utilities/extend_jest'; +import { urlSearch } from '../test_utilities/url_search'; + +let simulator: Simulator; +let databaseDocumentID: string; +let entityIDs: { origin: string; firstChild: string; secondChild: string }; + +// the resolver component instance ID, used by the react code to distinguish piece of global state from those used by other resolver instances +const resolverComponentInstanceID = 'oldID'; + +describe('Resolver, when analyzing a tree that has no ancestors and 2 children', () => { + beforeEach(async () => { + // create a mock data access layer + const { metadata: dataAccessLayerMetadata, dataAccessLayer } = noAncestorsTwoChildren(); + + // save a reference to the entity IDs exposed by the mock data layer + entityIDs = dataAccessLayerMetadata.entityIDs; + + // save a reference to the `_id` supported by the mock data layer + databaseDocumentID = dataAccessLayerMetadata.databaseDocumentID; + + // create a resolver simulator, using the data access layer and an arbitrary component instance ID + simulator = new Simulator({ databaseDocumentID, dataAccessLayer, resolverComponentInstanceID }); + }); + + describe("when the second child node's first button has been clicked", () => { + beforeEach(async () => { + const node = await simulator.resolveWrapper(() => + simulator.processNodeElements({ entityID: entityIDs.secondChild }).find('button') + ); + if (node) { + // Click the first button under the second child element. + node.first().simulate('click'); + } + }); + const expectedSearch = urlSearch(resolverComponentInstanceID, { + selectedEntityID: 'secondChild', + }); + it(`should have a url search of ${expectedSearch}`, async () => { + await expect(simulator.map(() => simulator.historyLocationSearch)).toYieldEqualTo( + urlSearch(resolverComponentInstanceID, { selectedEntityID: 'secondChild' }) + ); + }); + describe('when the resolver component gets unmounted', () => { + beforeEach(() => { + simulator.unmount(); + }); + it('should have a history location search of `""`', async () => { + await expect(simulator.map(() => simulator.historyLocationSearch)).toYieldEqualTo(''); + }); + }); + describe('when the resolver component has its component instance ID changed', () => { + const newInstanceID = 'newID'; + beforeEach(() => { + simulator.resolverComponentInstanceID = newInstanceID; + }); + it('should have a history location search of `""`', async () => { + await expect(simulator.map(() => simulator.historyLocationSearch)).toYieldEqualTo(''); + }); + describe("when the user clicks the second child node's button again", () => { + beforeEach(async () => { + const node = await simulator.resolveWrapper(() => + simulator.processNodeElements({ entityID: entityIDs.secondChild }).find('button') + ); + if (node) { + // Click the first button under the second child element. + node.first().simulate('click'); + } + }); + it(`should have a url search of ${urlSearch(newInstanceID, { + selectedEntityID: 'secondChild', + })}`, async () => { + await expect(simulator.map(() => simulator.historyLocationSearch)).toYieldEqualTo( + urlSearch(newInstanceID, { selectedEntityID: 'secondChild' }) + ); + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx index 5f1e5f18e575d..32faeec043f2d 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/resolver_without_providers.tsx @@ -8,9 +8,9 @@ import React, { useContext, useCallback } from 'react'; import { useSelector } from 'react-redux'; -import { useEffectOnce } from 'react-use'; import { EuiLoadingSpinner } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; +import { useResolverQueryParamCleaner } from './use_resolver_query_params_cleaner'; import * as selectors from '../store/selectors'; import { EdgeLine } from './edge_line'; import { GraphControls } from './graph_controls'; @@ -18,7 +18,6 @@ import { ProcessEventDot } from './process_event_dot'; import { useCamera } from './use_camera'; import { SymbolDefinitions, useResolverTheme } from './assets'; import { useStateSyncingActions } from './use_state_syncing_actions'; -import { useResolverQueryParams } from './use_resolver_query_params'; import { StyledMapContainer, StyledPanel, GraphContainer } from './styles'; import { entityIDSafeVersion } from '../../../common/endpoint/models/event'; import { SideEffectContext } from './side_effect_context'; @@ -35,6 +34,7 @@ export const ResolverWithoutProviders = React.memo( { className, databaseDocumentID, resolverComponentInstanceID }: ResolverProps, refToForward ) { + useResolverQueryParamCleaner(); /** * This is responsible for dispatching actions that include any external data. * `databaseDocumentID` @@ -70,11 +70,6 @@ export const ResolverWithoutProviders = React.memo( const hasError = useSelector(selectors.hasError); const activeDescendantId = useSelector(selectors.ariaActiveDescendant); const { colorMap } = useResolverTheme(); - const { cleanUpQueryParams } = useResolverQueryParams(); - - useEffectOnce(() => { - return () => cleanUpQueryParams(); - }); return ( diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_query_string_keys.ts b/x-pack/plugins/security_solution/public/resolver/view/use_query_string_keys.ts new file mode 100644 index 0000000000000..11f1a30db72fc --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/use_query_string_keys.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useSelector } from 'react-redux'; +import * as selectors from '../store/selectors'; + +/** + * Get the query string keys used by this Resolver instance. + */ +export function useQueryStringKeys(): { idKey: string; eventKey: string } { + const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); + const idKey: string = `resolver-${resolverComponentInstanceID}-id`; + const eventKey: string = `resolver-${resolverComponentInstanceID}-event`; + return { + idKey, + eventKey, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts index ed514a61d4e06..aa0851916a7b4 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -5,11 +5,8 @@ */ import { useCallback, useMemo } from 'react'; -// eslint-disable-next-line import/no-nodejs-modules -import querystring from 'querystring'; -import { useSelector } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; -import * as selectors from '../store/selectors'; +import { useQueryStringKeys } from './use_query_string_keys'; import { CrumbInfo } from './panels/panel_content_utilities'; export function useResolverQueryParams() { @@ -19,63 +16,40 @@ export function useResolverQueryParams() { */ const history = useHistory(); const urlSearch = useLocation().search; - const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); - const uniqueCrumbIdKey: string = `resolver-${resolverComponentInstanceID}-id`; - const uniqueCrumbEventKey: string = `resolver-${resolverComponentInstanceID}-event`; + const { idKey, eventKey } = useQueryStringKeys(); const pushToQueryParams = useCallback( - (newCrumbs: CrumbInfo) => { - // Construct a new set of parameters from the current set (minus empty parameters) - // by assigning the new set of parameters provided in `newCrumbs` - const crumbsToPass = { - ...querystring.parse(urlSearch.slice(1)), - [uniqueCrumbIdKey]: newCrumbs.crumbId, - [uniqueCrumbEventKey]: newCrumbs.crumbEvent, - }; + (queryStringState: CrumbInfo) => { + const urlSearchParams = new URLSearchParams(urlSearch); - // If either was passed in as empty, remove it from the record - if (newCrumbs.crumbId === '') { - delete crumbsToPass[uniqueCrumbIdKey]; + urlSearchParams.set(idKey, queryStringState.crumbId); + urlSearchParams.set(eventKey, queryStringState.crumbEvent); + + // If either was passed in as empty, remove it + if (queryStringState.crumbId === '') { + urlSearchParams.delete(idKey); } - if (newCrumbs.crumbEvent === '') { - delete crumbsToPass[uniqueCrumbEventKey]; + if (queryStringState.crumbEvent === '') { + urlSearchParams.delete(eventKey); } - const relativeURL = { search: querystring.stringify(crumbsToPass) }; + const relativeURL = { search: urlSearchParams.toString() }; // We probably don't want to nuke the user's history with a huge // trail of these, thus `.replace` instead of `.push` return history.replace(relativeURL); }, - [history, urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey] + [history, urlSearch, idKey, eventKey] ); const queryParams: CrumbInfo = useMemo(() => { - const parsed = querystring.parse(urlSearch.slice(1)); - const crumbEvent = parsed[uniqueCrumbEventKey]; - const crumbId = parsed[uniqueCrumbIdKey]; - function valueForParam(param: string | string[]): string { - if (Array.isArray(param)) { - return param[0] || ''; - } - return param || ''; - } + const urlSearchParams = new URLSearchParams(urlSearch); return { - crumbEvent: valueForParam(crumbEvent), - crumbId: valueForParam(crumbId), + // Use `''` for backwards compatibility with deprecated code. + crumbEvent: urlSearchParams.get(eventKey) ?? '', + crumbId: urlSearchParams.get(idKey) ?? '', }; - }, [urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey]); - - const cleanUpQueryParams = () => { - const crumbsToPass = { - ...querystring.parse(urlSearch.slice(1)), - }; - delete crumbsToPass[uniqueCrumbIdKey]; - delete crumbsToPass[uniqueCrumbEventKey]; - const relativeURL = { search: querystring.stringify(crumbsToPass) }; - history.replace(relativeURL); - }; + }, [urlSearch, idKey, eventKey]); return { pushToQueryParams, queryParams, - cleanUpQueryParams, }; } diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params_cleaner.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params_cleaner.ts new file mode 100644 index 0000000000000..a84eb0490aae2 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params_cleaner.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useRef, useEffect } from 'react'; +import { useLocation, useHistory } from 'react-router-dom'; + +import { useQueryStringKeys } from './use_query_string_keys'; +/** + * Cleanup any query string keys that were added by this Resolver instance. + * This works by having a React effect that just has behavior in the 'cleanup' function. + */ +export function useResolverQueryParamCleaner() { + /** + * Keep a reference to the current search value. This is used in the cleanup function. + * This value of useLocation().search isn't used directly since that would change and + * we only want the cleanup to run on unmount or when the resolverComponentInstanceID + * changes. + */ + const searchRef = useRef(); + searchRef.current = useLocation().search; + + const history = useHistory(); + + const { idKey, eventKey } = useQueryStringKeys(); + + useEffect(() => { + /** + * Keep track of the old query string keys so we can remove them. + */ + const oldIdKey = idKey; + const oldEventKey = eventKey; + /** + * When `idKey` or `eventKey` changes (such as when the `resolverComponentInstanceID` has changed) or when the component unmounts, remove any state from the query string. + */ + return () => { + /** + * This effect must not be invalidated when `search` changes. + */ + const urlSearchParams = new URLSearchParams(searchRef.current); + + /** + * Remove old keys from the url + */ + urlSearchParams.delete(oldIdKey); + urlSearchParams.delete(oldEventKey); + const relativeURL = { search: urlSearchParams.toString() }; + history.replace(relativeURL); + }; + }, [idKey, eventKey, history]); +} From 447854d992883f6dafc27743372701452ce7e8c4 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Thu, 13 Aug 2020 15:06:59 -0700 Subject: [PATCH 04/51] [Reporting/Functional] unskip pagination test (#74973) * [Reporting/Functional] unskip pagination test * change to js file for flaky test runner * fix ts --- .../__snapshots__/report_listing.test.tsx.snap | 3 --- .../reporting/public/components/report_listing.tsx | 1 - .../reporting_management/{index.ts => index.js} | 4 +--- .../apps/reporting_management/report_listing.ts | 14 ++++++++++---- 4 files changed, 11 insertions(+), 11 deletions(-) rename x-pack/test/functional/apps/reporting_management/{index.ts => index.js} (75%) diff --git a/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap b/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap index 66c3aea8acc13..ddba7842f1199 100644 --- a/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap +++ b/x-pack/plugins/reporting/public/components/__snapshots__/report_listing.test.tsx.snap @@ -30,7 +30,6 @@ Array [ }, ] } - data-test-page={0} data-test-subj="reportJobListing" isSelectable={true} itemId="id" @@ -57,7 +56,6 @@ Array [ >
@@ -368,7 +366,6 @@ Array [ ,
diff --git a/x-pack/plugins/reporting/public/components/report_listing.tsx b/x-pack/plugins/reporting/public/components/report_listing.tsx index 80ef9311fd0e5..afcae93a8db16 100644 --- a/x-pack/plugins/reporting/public/components/report_listing.tsx +++ b/x-pack/plugins/reporting/public/components/report_listing.tsx @@ -513,7 +513,6 @@ class ReportListingUi extends Component { isSelectable={true} onChange={this.onTableChange} data-test-subj="reportJobListing" - data-test-page={this.state.page} /> {this.state.selectedJobs.length > 0 ? this.renderDeleteButton() : null} diff --git a/x-pack/test/functional/apps/reporting_management/index.ts b/x-pack/test/functional/apps/reporting_management/index.js similarity index 75% rename from x-pack/test/functional/apps/reporting_management/index.ts rename to x-pack/test/functional/apps/reporting_management/index.js index 8606c46053ab0..ef92e7d04ef0c 100644 --- a/x-pack/test/functional/apps/reporting_management/index.ts +++ b/x-pack/test/functional/apps/reporting_management/index.js @@ -4,9 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default ({ loadTestFile }: FtrProviderContext) => { +export default ({ loadTestFile }) => { describe('reporting management app', function () { this.tags('ciGroup7'); loadTestFile(require.resolve('./report_listing')); diff --git a/x-pack/test/functional/apps/reporting_management/report_listing.ts b/x-pack/test/functional/apps/reporting_management/report_listing.ts index 476f3e73d0923..ca5fb888e67e1 100644 --- a/x-pack/test/functional/apps/reporting_management/report_listing.ts +++ b/x-pack/test/functional/apps/reporting_management/report_listing.ts @@ -26,7 +26,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const security = getService('security'); const testSubjects = getService('testSubjects'); - const findInstance = getService('find'); const esArchiver = getService('esArchiver'); describe('Listing of Reports', function () { @@ -68,7 +67,10 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); - it.skip('Paginates content', async () => { + it('Paginates historical reports', async () => { + // wait for first row of page 1 + await testSubjects.find('checkboxSelectRow-k9a9xlwl0gpe1457b10rraq3'); + const previousButton = await testSubjects.find('pagination-button-previous'); // previous CAN NOT be clicked @@ -90,7 +92,9 @@ pdf\ndashboard\n2020-04-21 @ 07:00 PM\ntest_user\nCompleted at 2020-04-21 @ 07:0 // click page 2 await testSubjects.click('pagination-button-1'); - await findInstance.byCssSelector('[data-test-page="1"]'); + + // wait for first row of page 2 + await testSubjects.find('checkboxSelectRow-k9a9uc4x0gpe1457b16wthc8'); // previous CAN be clicked expect(await previousButton.getAttribute('disabled')).to.be(null); @@ -110,7 +114,9 @@ test_user\nCompleted at 2020-04-21 @ 06:55 PM - Max size reached\nreport2csv\n20 // click page 3 await testSubjects.click('pagination-button-2'); - await findInstance.byCssSelector('[data-test-page="2"]'); + + // wait for first row of page 3 + await testSubjects.find('checkboxSelectRow-k9a9p1840gpe1457b1ghfxw5'); // scan page 3 tableText = await getTableTextFromElement(await testSubjects.find('reportJobListing')); From 24c2e0a4523926eb1d144fec5fc830b7320929a5 Mon Sep 17 00:00:00 2001 From: Josh Dover Date: Thu, 13 Aug 2020 18:15:11 -0600 Subject: [PATCH 05/51] Remove degraded state from ES status service (#75007) --- src/core/server/elasticsearch/status.test.ts | 6 +++--- src/core/server/elasticsearch/status.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/server/elasticsearch/status.test.ts b/src/core/server/elasticsearch/status.test.ts index ef7ca7cd04608..5dfadba4c88b2 100644 --- a/src/core/server/elasticsearch/status.test.ts +++ b/src/core/server/elasticsearch/status.test.ts @@ -65,7 +65,7 @@ describe('calculateStatus', () => { }); }); - it('changes to degraded when isCompatible and warningNodes present', async () => { + it('changes to available with a differemnt message when isCompatible and warningNodes present', async () => { expect( await calculateStatus$( of({ @@ -81,7 +81,7 @@ describe('calculateStatus', () => { .pipe(take(2)) .toPromise() ).toEqual({ - level: ServiceStatusLevels.degraded, + level: ServiceStatusLevels.available, summary: 'Some nodes are a different version', meta: { incompatibleNodes: [], @@ -188,7 +188,7 @@ describe('calculateStatus', () => { "summary": "Incompatible with Elasticsearch", }, Object { - "level": degraded, + "level": available, "meta": Object { "incompatibleNodes": Array [], "warningNodes": Array [ diff --git a/src/core/server/elasticsearch/status.ts b/src/core/server/elasticsearch/status.ts index 1eaa338af1239..1be32d03c60cb 100644 --- a/src/core/server/elasticsearch/status.ts +++ b/src/core/server/elasticsearch/status.ts @@ -55,7 +55,7 @@ export const calculateStatus$ = ( }; } else if (warningNodes.length > 0) { return { - level: ServiceStatusLevels.degraded, + level: ServiceStatusLevels.available, summary: // Message should always be present, but this is a safe fallback message ?? From 1632391f35d847d32245c502d0310849ae7b9322 Mon Sep 17 00:00:00 2001 From: Chris Cowan Date: Thu, 13 Aug 2020 18:45:47 -0700 Subject: [PATCH 06/51] [Metrics UI] Remove TSVB dependency from Metrics Explorer APIs (#74804) * [Metrics UI] Remove TSVB dependency from Metrics Explorer APIs * Update x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_title.tsx Co-authored-by: Zacqary Adam Xeper * Update x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_auto.ts Co-authored-by: Zacqary Adam Xeper * Update x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_auto.ts Co-authored-by: Zacqary Adam Xeper * Update x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts Co-authored-by: Zacqary Adam Xeper * Update x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts Co-authored-by: Zacqary Adam Xeper * Update x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts Co-authored-by: Zacqary Adam Xeper * Fixing some names, changing some units * Reverting TSVB calculate_auto; fixing names in infra * Fixing translation names * Fixing typo Co-authored-by: Zacqary Adam Xeper Co-authored-by: Zacqary Adam Xeper --- x-pack/plugins/infra/common/constants.ts | 3 + x-pack/plugins/infra/common/http_api/index.ts | 1 + .../infra/common/http_api/metrics_api.ts | 97 +++++++++ .../infra/common/http_api/metrics_explorer.ts | 2 + .../aws_ec2/metrics/snapshot/cpu.ts | 4 +- .../metrics/snapshot/disk_io_read_bytes.ts | 4 +- .../metrics/snapshot/disk_io_write_bytes.ts | 4 +- .../aws_ec2/metrics/snapshot/rx.ts | 4 +- .../aws_ec2/metrics/snapshot/tx.ts | 4 +- .../aws_rds/metrics/snapshot/cpu.ts | 4 +- .../snapshot/rds_active_transactions.ts | 4 +- .../metrics/snapshot/rds_connections.ts | 4 +- .../aws_rds/metrics/snapshot/rds_latency.ts | 4 +- .../metrics/snapshot/rds_queries_executed.ts | 4 +- .../aws_s3/metrics/snapshot/s3_bucket_size.ts | 4 +- .../metrics/snapshot/s3_download_bytes.ts | 4 +- .../metrics/snapshot/s3_number_of_objects.ts | 4 +- .../metrics/snapshot/s3_total_requests.ts | 4 +- .../metrics/snapshot/s3_upload_bytes.ts | 4 +- .../metrics/snapshot/sqs_messages_delayed.ts | 4 +- .../metrics/snapshot/sqs_messages_empty.ts | 4 +- .../metrics/snapshot/sqs_messages_sent.ts | 4 +- .../metrics/snapshot/sqs_messages_visible.ts | 4 +- .../metrics/snapshot/sqs_oldest_message.ts | 4 +- .../container/metrics/snapshot/cpu.ts | 4 +- .../container/metrics/snapshot/memory.ts | 6 +- .../host/metrics/snapshot/cpu.ts | 4 +- .../host/metrics/snapshot/load.ts | 4 +- .../host/metrics/snapshot/log_rate.ts | 4 +- .../host/metrics/snapshot/memory.ts | 4 +- .../infra/common/inventory_models/index.ts | 4 +- .../pod/metrics/snapshot/cpu.ts | 4 +- .../pod/metrics/snapshot/memory.ts | 4 +- .../shared/metrics/snapshot/count.ts | 4 +- .../metrics/snapshot/network_traffic.ts | 4 +- .../network_traffic_with_interfaces.ts | 4 +- .../shared/metrics/snapshot/rate.ts | 4 +- .../infra/common/inventory_models/types.ts | 55 +++-- .../indices_configuration_panel.tsx | 3 +- .../components/chart_title.tsx | 7 +- .../components/helpers/get_metric_id.ts | 3 - .../infra/server/lib/metrics/constants.ts | 16 ++ .../plugins/infra/server/lib/metrics/index.ts | 113 ++++++++++ ...stogram_buckets_to_timeseries.test.ts.snap | 193 ++++++++++++++++++ .../create_aggregations.test.ts.snap | 87 ++++++++ .../create_metrics_aggregations.test.ts.snap | 23 +++ .../calculate_auto.test.ts | 28 +++ .../calculate_bucket_size/calculate_auto.ts | 88 ++++++++ .../calculate_bucket_size.test.ts | 53 +++++ .../lib/calculate_bucket_size/index.ts | 89 ++++++++ .../interval_regex.test.ts | 81 ++++++++ .../calculate_bucket_size/interval_regex.ts | 10 + .../unit_to_seconds.test.ts | 132 ++++++++++++ .../calculate_bucket_size/unit_to_seconds.ts | 68 ++++++ .../calculate_date_histogram_offset.test.ts | 20 ++ .../lib/calculate_date_histogram_offset.ts | 17 ++ ...rt_histogram_buckets_to_timeseries.test.ts | 120 +++++++++++ ...convert_histogram_buckets_to_timeseries.ts | 93 +++++++++ .../metrics/lib/create_aggregations.test.ts | 45 ++++ .../lib/metrics/lib/create_aggregations.ts | 45 ++++ .../lib/create_metrics_aggregations.test.ts | 36 ++++ .../lib/create_metrics_aggregations.ts | 15 ++ .../plugins/infra/server/lib/metrics/types.ts | 72 +++++++ .../create_timerange_with_interval.ts | 6 +- .../server/lib/snapshot/query_helpers.ts | 10 +- .../infra/server/lib/sources/defaults.ts | 11 +- .../server/routes/metrics_explorer/index.ts | 64 ++++-- ...nvert_metric_to_metrics_api_metric.test.ts | 87 ++++++++ .../convert_metric_to_metrics_api_metric.ts | 62 ++++++ ...ert_request_to_metrics_api_options.test.ts | 123 +++++++++++ .../convert_request_to_metrics_api_options.ts | 58 ++++++ .../lib/create_metrics_model.ts | 97 --------- .../lib/find_interval_for_metrics.ts | 52 +++++ .../metrics_explorer/lib/get_groupings.ts | 150 -------------- .../lib/populate_series_with_tsvb_data.ts | 162 --------------- .../lib/query_total_groupings.ts | 59 ++++++ .../metrics_explorer/lib/transform_series.ts | 24 +++ .../translations/translations/ja-JP.json | 2 +- .../translations/translations/zh-CN.json | 2 +- .../apis/metrics_ui/metrics_explorer.ts | 6 +- 80 files changed, 2190 insertions(+), 534 deletions(-) create mode 100644 x-pack/plugins/infra/common/http_api/metrics_api.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/constants.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/index.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/convert_histogram_buckets_to_timeseries.test.ts.snap create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_metrics_aggregations.test.ts.snap create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_auto.test.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_auto.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_bucket_size.test.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/index.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/interval_regex.test.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/interval_regex.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/unit_to_seconds.test.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/unit_to_seconds.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/calculate_date_histogram_offset.test.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/calculate_date_histogram_offset.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.test.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.test.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/create_metrics_aggregations.test.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/lib/create_metrics_aggregations.ts create mode 100644 x-pack/plugins/infra/server/lib/metrics/types.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.test.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.test.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.ts delete mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts delete mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_groupings.ts delete mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts create mode 100644 x-pack/plugins/infra/server/routes/metrics_explorer/lib/transform_series.ts diff --git a/x-pack/plugins/infra/common/constants.ts b/x-pack/plugins/infra/common/constants.ts index 65dcb2e43c6f7..ba8e421cbf32c 100644 --- a/x-pack/plugins/infra/common/constants.ts +++ b/x-pack/plugins/infra/common/constants.ts @@ -5,3 +5,6 @@ */ export const DEFAULT_SOURCE_ID = 'default'; +export const METRICS_INDEX_PATTERN = 'metrics-*,metricbeat-*'; +export const LOGS_INDEX_PATTERN = 'logs-*,filebeat-*,kibana_sample_data_logs*'; +export const TIMESTAMP_FIELD = '@timestamp'; diff --git a/x-pack/plugins/infra/common/http_api/index.ts b/x-pack/plugins/infra/common/http_api/index.ts index 326daa93de33a..9ec8bf5231066 100644 --- a/x-pack/plugins/infra/common/http_api/index.ts +++ b/x-pack/plugins/infra/common/http_api/index.ts @@ -8,3 +8,4 @@ export * from './log_analysis'; export * from './metadata_api'; export * from './log_entries'; export * from './metrics_explorer'; +export * from './metrics_api'; diff --git a/x-pack/plugins/infra/common/http_api/metrics_api.ts b/x-pack/plugins/infra/common/http_api/metrics_api.ts new file mode 100644 index 0000000000000..7436566f039ca --- /dev/null +++ b/x-pack/plugins/infra/common/http_api/metrics_api.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import { MetricsUIAggregationRT } from '../inventory_models/types'; +import { afterKeyObjectRT } from './metrics_explorer'; + +export const MetricsAPITimerangeRT = rt.type({ + field: rt.string, + from: rt.number, + to: rt.number, + interval: rt.string, +}); + +const groupByRT = rt.union([rt.string, rt.null, rt.undefined]); + +export const MetricsAPIMetricRT = rt.type({ + id: rt.string, + aggregations: MetricsUIAggregationRT, +}); + +export const MetricsAPIRequestRT = rt.intersection([ + rt.type({ + timerange: MetricsAPITimerangeRT, + indexPattern: rt.string, + metrics: rt.array(MetricsAPIMetricRT), + }), + rt.partial({ + groupBy: rt.array(groupByRT), + afterKey: rt.union([rt.null, afterKeyObjectRT]), + limit: rt.union([rt.number, rt.null, rt.undefined]), + filters: rt.array(rt.object), + forceInterval: rt.boolean, + dropLastBucket: rt.boolean, + alignDataToEnd: rt.boolean, + }), +]); + +export const MetricsAPIPageInfoRT = rt.type({ + afterKey: rt.union([rt.null, afterKeyObjectRT, rt.undefined]), + interval: rt.number, +}); + +export const MetricsAPIColumnTypeRT = rt.keyof({ + date: null, + number: null, + string: null, +}); + +export const MetricsAPIColumnRT = rt.type({ + name: rt.string, + type: MetricsAPIColumnTypeRT, +}); + +export const MetricsAPIRowRT = rt.intersection([ + rt.type({ + timestamp: rt.number, + }), + rt.record(rt.string, rt.union([rt.string, rt.number, rt.null, rt.undefined])), +]); + +export const MetricsAPISeriesRT = rt.intersection([ + rt.type({ + id: rt.string, + columns: rt.array(MetricsAPIColumnRT), + rows: rt.array(MetricsAPIRowRT), + }), + rt.partial({ + keys: rt.array(rt.string), + }), +]); + +export const MetricsAPIResponseRT = rt.type({ + series: rt.array(MetricsAPISeriesRT), + info: MetricsAPIPageInfoRT, +}); + +export type MetricsAPITimerange = rt.TypeOf; + +export type MetricsAPIColumnType = rt.TypeOf; + +export type MetricsAPIMetric = rt.TypeOf; + +export type MetricsAPIPageInfo = rt.TypeOf; + +export type MetricsAPIColumn = rt.TypeOf; + +export type MetricsAPIRow = rt.TypeOf; + +export type MetricsAPISeries = rt.TypeOf; + +export type MetricsAPIRequest = rt.TypeOf; + +export type MetricsAPIResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/http_api/metrics_explorer.ts b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts index 0f63b8d275e65..c5776e0b0ced1 100644 --- a/x-pack/plugins/infra/common/http_api/metrics_explorer.ts +++ b/x-pack/plugins/infra/common/http_api/metrics_explorer.ts @@ -108,6 +108,8 @@ export const metricsExplorerResponseRT = rt.type({ pageInfo: metricsExplorerPageInfoRT, }); +export type AfterKey = rt.TypeOf; + export type MetricsExplorerAggregation = rt.TypeOf; export type MetricsExplorerColumnType = rt.TypeOf; diff --git a/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/cpu.ts b/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/cpu.ts index 483d9de784919..ea6db373eda03 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/cpu.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/cpu.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const cpu: SnapshotModel = { +export const cpu: MetricsUIAggregation = { cpu_avg: { avg: { field: 'aws.ec2.cpu.total.pct', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/disk_io_read_bytes.ts b/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/disk_io_read_bytes.ts index 48e4a9eb59fad..89b131f9c2a8c 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/disk_io_read_bytes.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/disk_io_read_bytes.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const diskIOReadBytes: SnapshotModel = { +export const diskIOReadBytes: MetricsUIAggregation = { diskIOReadBytes: { avg: { field: 'aws.ec2.diskio.read.bytes_per_sec', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/disk_io_write_bytes.ts b/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/disk_io_write_bytes.ts index deadaa8c4a776..e52380fbddd33 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/disk_io_write_bytes.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/disk_io_write_bytes.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const diskIOWriteBytes: SnapshotModel = { +export const diskIOWriteBytes: MetricsUIAggregation = { diskIOWriteBytes: { avg: { field: 'aws.ec2.diskio.write.bytes_per_sec', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/rx.ts b/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/rx.ts index 2b857ce9b338a..ddb6770744699 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/rx.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/rx.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const rx: SnapshotModel = { +export const rx: MetricsUIAggregation = { rx: { avg: { field: 'aws.ec2.network.in.bytes_per_sec', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/tx.ts b/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/tx.ts index 63c9da8ea1888..cf3bb070c8ec8 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/tx.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_ec2/metrics/snapshot/tx.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const tx: SnapshotModel = { +export const tx: MetricsUIAggregation = { tx: { avg: { field: 'aws.ec2.network.in.bytes_per_sec', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/cpu.ts b/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/cpu.ts index e277b3b11958b..4825fb26aa677 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/cpu.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/cpu.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const cpu: SnapshotModel = { +export const cpu: MetricsUIAggregation = { cpu_avg: { avg: { field: 'aws.rds.cpu.total.pct', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_active_transactions.ts b/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_active_transactions.ts index be3dba100ba29..50c89a51a32da 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_active_transactions.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_active_transactions.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const rdsActiveTransactions: SnapshotModel = { +export const rdsActiveTransactions: MetricsUIAggregation = { rdsActiveTransactions: { avg: { field: 'aws.rds.transactions.active', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_connections.ts b/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_connections.ts index c7855d5548eea..4f8ab3bec324b 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_connections.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_connections.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const rdsConnections: SnapshotModel = { +export const rdsConnections: MetricsUIAggregation = { rdsConnections: { avg: { field: 'aws.rds.database_connections', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_latency.ts b/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_latency.ts index 2997b54d2f92e..1682cab04cd7a 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_latency.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_latency.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const rdsLatency: SnapshotModel = { +export const rdsLatency: MetricsUIAggregation = { rdsLatency: { avg: { field: 'aws.rds.latency.dml', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_queries_executed.ts b/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_queries_executed.ts index 18e6538fb1e1e..7672d17f9d048 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_queries_executed.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_rds/metrics/snapshot/rds_queries_executed.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const rdsQueriesExecuted: SnapshotModel = { +export const rdsQueriesExecuted: MetricsUIAggregation = { rdsQueriesExecuted: { avg: { field: 'aws.rds.queries', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_bucket_size.ts b/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_bucket_size.ts index a99753a39c97c..b087d63f95863 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_bucket_size.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_bucket_size.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const s3BucketSize: SnapshotModel = { +export const s3BucketSize: MetricsUIAggregation = { s3BucketSize: { max: { field: 'aws.s3_daily_storage.bucket.size.bytes', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_download_bytes.ts b/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_download_bytes.ts index a0b23dadee37a..393f09b7340ed 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_download_bytes.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_download_bytes.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const s3DownloadBytes: SnapshotModel = { +export const s3DownloadBytes: MetricsUIAggregation = { s3DownloadBytes: { max: { field: 'aws.s3_request.downloaded.bytes', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_number_of_objects.ts b/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_number_of_objects.ts index 29162a59db47a..4e360c93ae26c 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_number_of_objects.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_number_of_objects.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const s3NumberOfObjects: SnapshotModel = { +export const s3NumberOfObjects: MetricsUIAggregation = { s3NumberOfObjects: { max: { field: 'aws.s3_daily_storage.number_of_objects', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_total_requests.ts b/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_total_requests.ts index bc57c6eb38234..87fcd27efa1ec 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_total_requests.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_total_requests.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const s3TotalRequests: SnapshotModel = { +export const s3TotalRequests: MetricsUIAggregation = { s3TotalRequests: { max: { field: 'aws.s3_request.requests.total', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_upload_bytes.ts b/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_upload_bytes.ts index 977d73254c3cd..c5b7f50a39051 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_upload_bytes.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_s3/metrics/snapshot/s3_upload_bytes.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const s3UploadBytes: SnapshotModel = { +export const s3UploadBytes: MetricsUIAggregation = { s3UploadBytes: { max: { field: 'aws.s3_request.uploaded.bytes', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_delayed.ts b/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_delayed.ts index 679f86671725e..630c7946e4478 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_delayed.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_delayed.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const sqsMessagesDelayed: SnapshotModel = { +export const sqsMessagesDelayed: MetricsUIAggregation = { sqsMessagesDelayed: { max: { field: 'aws.sqs.messages.delayed', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_empty.ts b/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_empty.ts index d80a3f3451e1d..15d1cf9b5df81 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_empty.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_empty.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const sqsMessagesEmpty: SnapshotModel = { +export const sqsMessagesEmpty: MetricsUIAggregation = { sqsMessagesEmpty: { max: { field: 'aws.sqs.messages.not_visible', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_sent.ts b/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_sent.ts index 3d6934bf3da85..fd6ea2ee53ed2 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_sent.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_sent.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const sqsMessagesSent: SnapshotModel = { +export const sqsMessagesSent: MetricsUIAggregation = { sqsMessagesSent: { max: { field: 'aws.sqs.messages.sent', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_visible.ts b/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_visible.ts index 1a78c50cd7949..26a1084c63e46 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_visible.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_messages_visible.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const sqsMessagesVisible: SnapshotModel = { +export const sqsMessagesVisible: MetricsUIAggregation = { sqsMessagesVisible: { avg: { field: 'aws.sqs.messages.visible', diff --git a/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_oldest_message.ts b/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_oldest_message.ts index ae780069c8ca1..1314579319264 100644 --- a/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_oldest_message.ts +++ b/x-pack/plugins/infra/common/inventory_models/aws_sqs/metrics/snapshot/sqs_oldest_message.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const sqsOldestMessage: SnapshotModel = { +export const sqsOldestMessage: MetricsUIAggregation = { sqsOldestMessage: { max: { field: 'aws.sqs.oldest_message_age.sec', diff --git a/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/cpu.ts b/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/cpu.ts index a6c25ee260cac..d5a6229ff56f1 100644 --- a/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/cpu.ts +++ b/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/cpu.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const cpu: SnapshotModel = { +export const cpu: MetricsUIAggregation = { cpu: { avg: { field: 'docker.cpu.total.pct', diff --git a/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/memory.ts b/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/memory.ts index 30df0ebbaa1d4..693f9569edd77 100644 --- a/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/memory.ts +++ b/x-pack/plugins/infra/common/inventory_models/container/metrics/snapshot/memory.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const memory: SnapshotModel = { memory: { avg: { field: 'docker.memory.usage.pct' } } }; +export const memory: MetricsUIAggregation = { + memory: { avg: { field: 'docker.memory.usage.pct' } }, +}; diff --git a/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/cpu.ts b/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/cpu.ts index fa43acb8d6108..047593d65c546 100644 --- a/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/cpu.ts +++ b/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/cpu.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const cpu: SnapshotModel = { +export const cpu: MetricsUIAggregation = { cpu_user: { avg: { field: 'system.cpu.user.pct', diff --git a/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/load.ts b/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/load.ts index 803fb2664ad27..d42ed590f1bd8 100644 --- a/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/load.ts +++ b/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/load.ts @@ -4,6 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const load: SnapshotModel = { load: { avg: { field: 'system.load.5' } } }; +export const load: MetricsUIAggregation = { load: { avg: { field: 'system.load.5' } } }; diff --git a/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/log_rate.ts b/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/log_rate.ts index 658111bd07676..5e516d12eadf8 100644 --- a/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/log_rate.ts +++ b/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/log_rate.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const logRate: SnapshotModel = { +export const logRate: MetricsUIAggregation = { count: { bucket_script: { buckets_path: { count: '_count' }, diff --git a/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/memory.ts b/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/memory.ts index cb08a9eaebb3b..adfe831fa658d 100644 --- a/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/memory.ts +++ b/x-pack/plugins/infra/common/inventory_models/host/metrics/snapshot/memory.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const memory: SnapshotModel = { +export const memory: MetricsUIAggregation = { memory: { avg: { field: 'system.memory.actual.used.pct' } }, }; diff --git a/x-pack/plugins/infra/common/inventory_models/index.ts b/x-pack/plugins/infra/common/inventory_models/index.ts index 1ddf92516c409..84bdb7887b1d1 100644 --- a/x-pack/plugins/infra/common/inventory_models/index.ts +++ b/x-pack/plugins/infra/common/inventory_models/index.ts @@ -51,9 +51,9 @@ const getFieldByType = (type: InventoryItemType, fields: InventoryFields) => { } }; -export const findInventoryFields = (type: InventoryItemType, fields: InventoryFields) => { +export const findInventoryFields = (type: InventoryItemType, fields?: InventoryFields) => { const inventoryModel = findInventoryModel(type); - if (LEGACY_TYPES.includes(type)) { + if (fields && LEGACY_TYPES.includes(type)) { const id = getFieldByType(type, fields) || inventoryModel.fields.id; return { ...inventoryModel.fields, diff --git a/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/cpu.ts b/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/cpu.ts index d5979d455f0bf..18292b9cf326d 100644 --- a/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/cpu.ts +++ b/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/cpu.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const cpu: SnapshotModel = { +export const cpu: MetricsUIAggregation = { cpu_with_limit: { avg: { field: 'kubernetes.pod.cpu.usage.limit.pct', diff --git a/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/memory.ts b/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/memory.ts index 28a71d9b0275a..9b78d625b73d6 100644 --- a/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/memory.ts +++ b/x-pack/plugins/infra/common/inventory_models/pod/metrics/snapshot/memory.ts @@ -4,8 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const memory: SnapshotModel = { +export const memory: MetricsUIAggregation = { memory: { avg: { field: 'kubernetes.pod.memory.usage.node.pct' } }, }; diff --git a/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/count.ts b/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/count.ts index ed8398a5d4a77..428d542542c4c 100644 --- a/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/count.ts +++ b/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/count.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const count: SnapshotModel = { +export const count: MetricsUIAggregation = { count: { bucket_script: { buckets_path: { count: '_count' }, diff --git a/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/network_traffic.ts b/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/network_traffic.ts index 37e90a6416ba7..28c866ff44b98 100644 --- a/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/network_traffic.ts +++ b/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/network_traffic.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const networkTraffic = (id: string, field: string): SnapshotModel => { +export const networkTraffic = (id: string, field: string): MetricsUIAggregation => { return { [`${id}_max`]: { max: { field } }, [`${id}_deriv`]: { diff --git a/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/network_traffic_with_interfaces.ts b/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/network_traffic_with_interfaces.ts index 1ba5cf037e708..3ba8f2eafcc27 100644 --- a/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/network_traffic_with_interfaces.ts +++ b/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/network_traffic_with_interfaces.ts @@ -4,13 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; export const networkTrafficWithInterfaces = ( id: string, metricField: string, interfaceField: string -): SnapshotModel => ({ +): MetricsUIAggregation => ({ [`${id}_interfaces`]: { terms: { field: interfaceField }, aggregations: { diff --git a/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/rate.ts b/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/rate.ts index e1c7c7df52628..312d4e8062988 100644 --- a/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/rate.ts +++ b/x-pack/plugins/infra/common/inventory_models/shared/metrics/snapshot/rate.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SnapshotModel } from '../../../types'; +import { MetricsUIAggregation } from '../../../types'; -export const rate = (id: string, field: string): SnapshotModel => { +export const rate = (id: string, field: string): MetricsUIAggregation => { return { [`${id}_max`]: { max: { field } }, [`${id}_deriv`]: { diff --git a/x-pack/plugins/infra/common/inventory_models/types.ts b/x-pack/plugins/infra/common/inventory_models/types.ts index 2c6432c3e5286..570220bbc7aa5 100644 --- a/x-pack/plugins/infra/common/inventory_models/types.ts +++ b/x-pack/plugins/infra/common/inventory_models/types.ts @@ -224,7 +224,7 @@ export type TSVBMetricModelCreator = ( interval: string ) => TSVBMetricModel; -export const SnapshotModelMetricAggRT = rt.record( +export const ESBasicMetricAggRT = rt.record( rt.string, rt.union([ rt.undefined, @@ -234,7 +234,21 @@ export const SnapshotModelMetricAggRT = rt.record( ]) ); -export const SnapshotModelBucketScriptRT = rt.type({ +export const ESPercentileAggRT = rt.type({ + percentiles: rt.type({ + field: rt.string, + percents: rt.array(rt.number), + }), +}); + +export const ESCaridnalityAggRT = rt.type({ + cardinality: rt.partial({ + field: rt.string, + script: rt.string, + }), +}); + +export const ESBucketScriptAggRT = rt.type({ bucket_script: rt.intersection([ rt.type({ buckets_path: rt.record(rt.string, rt.union([rt.undefined, rt.string])), @@ -247,13 +261,13 @@ export const SnapshotModelBucketScriptRT = rt.type({ ]), }); -export const SnapshotModelCumulativeSumRT = rt.type({ +export const ESCumulativeSumAggRT = rt.type({ cumulative_sum: rt.type({ buckets_path: rt.string, }), }); -export const SnapshotModelDerivativeRT = rt.type({ +export const ESDerivativeAggRT = rt.type({ derivative: rt.type({ buckets_path: rt.string, gap_policy: rt.keyof({ skip: null, insert_zeros: null }), @@ -261,7 +275,7 @@ export const SnapshotModelDerivativeRT = rt.type({ }), }); -export const SnapshotModelSumBucketRT = rt.type({ +export const ESSumBucketAggRT = rt.type({ sum_bucket: rt.type({ buckets_path: rt.string, }), @@ -269,32 +283,31 @@ export const SnapshotModelSumBucketRT = rt.type({ interface SnapshotTermsWithAggregation { terms: { field: string }; - aggregations: SnapshotModel; + aggregations: MetricsUIAggregation; } -export const SnapshotTermsWithAggregationRT: rt.Type = rt.recursion( +export const ESTermsWithAggregationRT: rt.Type = rt.recursion( 'SnapshotModelRT', () => rt.type({ terms: rt.type({ field: rt.string }), - aggregations: SnapshotModelRT, + aggregations: MetricsUIAggregationRT, }) ); -export const SnapshotModelAggregationRT = rt.union([ - SnapshotModelMetricAggRT, - SnapshotModelBucketScriptRT, - SnapshotModelCumulativeSumRT, - SnapshotModelDerivativeRT, - SnapshotModelSumBucketRT, - SnapshotTermsWithAggregationRT, +export const ESAggregationRT = rt.union([ + ESBasicMetricAggRT, + ESPercentileAggRT, + ESBucketScriptAggRT, + ESCumulativeSumAggRT, + ESDerivativeAggRT, + ESSumBucketAggRT, + ESTermsWithAggregationRT, + ESCaridnalityAggRT, ]); -export const SnapshotModelRT = rt.record( - rt.string, - rt.union([rt.undefined, SnapshotModelAggregationRT]) -); -export type SnapshotModel = rt.TypeOf; +export const MetricsUIAggregationRT = rt.record(rt.string, ESAggregationRT); +export type MetricsUIAggregation = rt.TypeOf; export const SnapshotMetricTypeRT = rt.keyof({ count: null, @@ -327,7 +340,7 @@ export type SnapshotMetricType = rt.TypeOf; export interface InventoryMetrics { tsvb: { [name: string]: TSVBMetricModelCreator }; - snapshot: { [name: string]: SnapshotModel }; + snapshot: { [name: string]: MetricsUIAggregation }; defaultSnapshot: SnapshotMetricType; /** This is used by the inventory view to calculate the appropriate amount of time for the metrics detail page. Some metris like awsS3 require multiple days where others like host only need an hour.*/ defaultTimeRangeInSeconds: number; diff --git a/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx b/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx index e9817331ace93..d5fafe3ce41a0 100644 --- a/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx +++ b/x-pack/plugins/infra/public/components/source_configuration/indices_configuration_panel.tsx @@ -16,6 +16,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import React from 'react'; +import { METRICS_INDEX_PATTERN } from '../../../common/constants'; import { InputFieldProps } from './input_fields'; interface IndicesConfigurationPanelProps { @@ -63,7 +64,7 @@ export const IndicesConfigurationPanel = ({ id="xpack.infra.sourceConfiguration.metricIndicesRecommendedValue" defaultMessage="The recommended value is {defaultValue}" values={{ - defaultValue: metrics-*,metricbeat-*, + defaultValue: {METRICS_INDEX_PATTERN}, }} /> } diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_title.tsx b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_title.tsx index e756c3bc393ce..c40aef5888ad7 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_title.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/chart_title.tsx @@ -6,12 +6,17 @@ import React, { Fragment } from 'react'; import { EuiText, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import { MetricsExplorerSeries } from '../../../../../common/http_api'; interface Props { series: MetricsExplorerSeries; } +const ALL_TITLE = i18n.translate('xpack.infra.metricsExplorer.everything', { + defaultMessage: 'Everything', +}); + export const ChartTitle = ({ series }: Props) => { if (series.keys != null) { const { keys } = series; @@ -21,7 +26,7 @@ export const ChartTitle = ({ series }: Props) => { i ? 'subdued' : 'default'}> - {name} + {name === '*' ? ALL_TITLE : name} {keys.length - 1 > i && ( diff --git a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/get_metric_id.ts b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/get_metric_id.ts index 35ca2561b0862..17548de9b2e78 100644 --- a/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/get_metric_id.ts +++ b/x-pack/plugins/infra/public/pages/metrics/metrics_explorer/components/helpers/get_metric_id.ts @@ -7,8 +7,5 @@ import { MetricsExplorerOptionsMetric } from '../../hooks/use_metrics_explorer_options'; export const getMetricId = (metric: MetricsExplorerOptionsMetric, index: string | number) => { - if (['p95', 'p99'].includes(metric.aggregation)) { - return `metric_${index}:percentile_0`; - } return `metric_${index}`; }; diff --git a/x-pack/plugins/infra/server/lib/metrics/constants.ts b/x-pack/plugins/infra/server/lib/metrics/constants.ts new file mode 100644 index 0000000000000..590eaf5605c72 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/constants.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export const EMPTY_RESPONSE = { + series: [ + { + id: '*', + keys: ['*'], + columns: [], + rows: [], + }, + ], + info: { total: 0, afterKey: null, interval: 0 }, +}; diff --git a/x-pack/plugins/infra/server/lib/metrics/index.ts b/x-pack/plugins/infra/server/lib/metrics/index.ts new file mode 100644 index 0000000000000..183254a0486a2 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/index.ts @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { set } from '@elastic/safer-lodash-set'; +import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; +import { MetricsAPIRequest, MetricsAPIResponse, afterKeyObjectRT } from '../../../common/http_api'; +import { + ESSearchClient, + GroupingResponseRT, + MetricsESResponse, + HistogramResponseRT, +} from './types'; +import { EMPTY_RESPONSE } from './constants'; +import { createAggregations } from './lib/create_aggregations'; +import { convertHistogramBucketsToTimeseries } from './lib/convert_histogram_buckets_to_timeseries'; +import { calculateBucketSize } from './lib/calculate_bucket_size'; + +export const query = async ( + search: ESSearchClient, + options: MetricsAPIRequest +): Promise => { + const hasGroupBy = Array.isArray(options.groupBy) && options.groupBy.length > 0; + const filter: Array> = [ + { + range: { + [options.timerange.field]: { + gte: options.timerange.from, + lte: options.timerange.to, + format: 'epoch_millis', + }, + }, + }, + ...(options.groupBy?.map((field) => ({ exists: { field } })) ?? []), + ]; + const params = { + allowNoIndices: true, + ignoreUnavailable: true, + index: options.indexPattern, + body: { + size: 0, + query: { bool: { filter } }, + aggs: { ...createAggregations(options) }, + }, + }; + + if (hasGroupBy) { + if (options.afterKey) { + if (afterKeyObjectRT.is(options.afterKey)) { + set(params, 'body.aggs.groupings.composite.after', options.afterKey); + } else { + set(params, 'body.aggs.groupings.composite.after', { groupBy0: options.afterKey }); + } + } + } + + if (options.filters) { + params.body.query.bool.filter = [...params.body.query.bool.filter, ...options.filters]; + } + + const response = await search<{}, MetricsESResponse>(params); + + if (response.hits.total.value === 0) { + return EMPTY_RESPONSE; + } + + if (!response.aggregations) { + throw new Error('Aggregations should be present.'); + } + + const { bucketSize } = calculateBucketSize(options.timerange); + + if (hasGroupBy && GroupingResponseRT.is(response.aggregations)) { + const { groupings } = response.aggregations; + const { after_key: afterKey } = groupings; + const limit = options.limit || 9; + const returnAfterKey = afterKey && groupings.buckets.length === limit ? true : false; + return { + series: groupings.buckets.map((bucket) => { + const keys = Object.values(bucket.key); + return convertHistogramBucketsToTimeseries(keys, options, bucket.histogram.buckets); + }), + info: { + afterKey: returnAfterKey ? afterKey : null, + interval: bucketSize, + }, + }; + } else if (hasGroupBy) { + ThrowReporter.report(GroupingResponseRT.decode(response.aggregations)); + } + + if (HistogramResponseRT.is(response.aggregations)) { + return { + series: [ + convertHistogramBucketsToTimeseries( + ['*'], + options, + response.aggregations.histogram.buckets + ), + ], + info: { + afterKey: null, + interval: bucketSize, + }, + }; + } else { + ThrowReporter.report(HistogramResponseRT.decode(response.aggregations)); + } + + throw new Error('Elasticsearch responsed with an unrecoginzed format.'); +}; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/convert_histogram_buckets_to_timeseries.test.ts.snap b/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/convert_histogram_buckets_to_timeseries.test.ts.snap new file mode 100644 index 0000000000000..9f4cd67c07b6b --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/convert_histogram_buckets_to_timeseries.test.ts.snap @@ -0,0 +1,193 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`convertHistogramBucketsToTimeseies(keys, options, buckets) should drop the last bucket 1`] = ` +Object { + "columns": Array [ + Object { + "name": "timestamp", + "type": "date", + }, + Object { + "name": "metric_0", + "type": "number", + }, + ], + "id": "example-0", + "keys": Array [ + "example-0", + ], + "rows": Array [ + Object { + "metric_0": 1, + "timestamp": 1577836800000, + }, + Object { + "metric_0": 1, + "timestamp": 1577836860000, + }, + Object { + "metric_0": 1, + "timestamp": 1577836920000, + }, + ], +} +`; + +exports[`convertHistogramBucketsToTimeseies(keys, options, buckets) should just work 1`] = ` +Object { + "columns": Array [ + Object { + "name": "timestamp", + "type": "date", + }, + Object { + "name": "metric_0", + "type": "number", + }, + ], + "id": "example-0", + "keys": Array [ + "example-0", + ], + "rows": Array [ + Object { + "metric_0": 1, + "timestamp": 1577836800000, + }, + Object { + "metric_0": 1, + "timestamp": 1577836860000, + }, + Object { + "metric_0": 1, + "timestamp": 1577836920000, + }, + Object { + "metric_0": null, + "timestamp": 1577836920000, + }, + ], +} +`; + +exports[`convertHistogramBucketsToTimeseies(keys, options, buckets) should return empty timeseries for empty metrics 1`] = ` +Object { + "columns": Array [], + "id": "example-0", + "keys": Array [ + "example-0", + ], + "rows": Array [], +} +`; + +exports[`convertHistogramBucketsToTimeseies(keys, options, buckets) should work with keyed percentiles 1`] = ` +Object { + "columns": Array [ + Object { + "name": "timestamp", + "type": "date", + }, + Object { + "name": "metric_0", + "type": "number", + }, + ], + "id": "example-0", + "keys": Array [ + "example-0", + ], + "rows": Array [ + Object { + "metric_0": 4, + "timestamp": 1577836800000, + }, + Object { + "metric_0": 4, + "timestamp": 1577836860000, + }, + Object { + "metric_0": 4, + "timestamp": 1577836920000, + }, + Object { + "metric_0": 4, + "timestamp": 1577836920000, + }, + ], +} +`; + +exports[`convertHistogramBucketsToTimeseies(keys, options, buckets) should work with normalized_values 1`] = ` +Object { + "columns": Array [ + Object { + "name": "timestamp", + "type": "date", + }, + Object { + "name": "metric_0", + "type": "number", + }, + ], + "id": "example-0", + "keys": Array [ + "example-0", + ], + "rows": Array [ + Object { + "metric_0": 2, + "timestamp": 1577836800000, + }, + Object { + "metric_0": 2, + "timestamp": 1577836860000, + }, + Object { + "metric_0": 2, + "timestamp": 1577836920000, + }, + Object { + "metric_0": null, + "timestamp": 1577836920000, + }, + ], +} +`; + +exports[`convertHistogramBucketsToTimeseies(keys, options, buckets) should work with percentiles 1`] = ` +Object { + "columns": Array [ + Object { + "name": "timestamp", + "type": "date", + }, + Object { + "name": "metric_0", + "type": "number", + }, + ], + "id": "example-0", + "keys": Array [ + "example-0", + ], + "rows": Array [ + Object { + "metric_0": 3, + "timestamp": 1577836800000, + }, + Object { + "metric_0": 3, + "timestamp": 1577836860000, + }, + Object { + "metric_0": 3, + "timestamp": 1577836920000, + }, + Object { + "metric_0": 3, + "timestamp": 1577836920000, + }, + ], +} +`; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap b/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap new file mode 100644 index 0000000000000..d2d90914eced5 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_aggregations.test.ts.snap @@ -0,0 +1,87 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createAggregations(options) should return add offset to histogram 1`] = ` +Object { + "histogram": Object { + "aggregations": Object { + "metric_0": Object { + "avg": Object { + "field": "system.cpu.user.pct", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1577838720000, + "min": 1577835120000, + }, + "field": "@timestamp", + "fixed_interval": "1m", + "offset": "-60s", + }, + }, +} +`; + +exports[`createAggregations(options) should return groupings aggregation with groupBy 1`] = ` +Object { + "groupings": Object { + "aggs": Object { + "histogram": Object { + "aggregations": Object { + "metric_0": Object { + "avg": Object { + "field": "system.cpu.user.pct", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1577840400000, + "min": 1577836800000, + }, + "field": "@timestamp", + "fixed_interval": "1m", + "offset": "0s", + }, + }, + }, + "composite": Object { + "size": 20, + "sources": Array [ + Object { + "groupBy0": Object { + "terms": Object { + "field": "host.name", + "order": "asc", + }, + }, + }, + ], + }, + }, +} +`; + +exports[`createAggregations(options) should return just histogram aggregation without groupBy 1`] = ` +Object { + "histogram": Object { + "aggregations": Object { + "metric_0": Object { + "avg": Object { + "field": "system.cpu.user.pct", + }, + }, + }, + "date_histogram": Object { + "extended_bounds": Object { + "max": 1577840400000, + "min": 1577836800000, + }, + "field": "@timestamp", + "fixed_interval": "1m", + "offset": "0s", + }, + }, +} +`; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_metrics_aggregations.test.ts.snap b/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_metrics_aggregations.test.ts.snap new file mode 100644 index 0000000000000..bbfe7e9cf0f9f --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/__snapshots__/create_metrics_aggregations.test.ts.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createMetricsAggregations(options) should just work 1`] = ` +Object { + "metric_0": Object { + "avg": Object { + "field": "system.cpu.user.pct", + }, + }, + "metric_1": Object { + "derivative": Object { + "buckets_path": "metric_1_max", + "gap_policy": "skip", + "unit": "1s", + }, + }, + "metric_1_max": Object { + "max": Object { + "field": "system.network.in.bytes", + }, + }, +} +`; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_auto.test.ts b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_auto.test.ts new file mode 100644 index 0000000000000..a2f8a08afc303 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_auto.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { calculateAuto } from './calculate_auto'; +import moment, { isDuration } from 'moment'; + +describe('calculateAuto.near(bucket, duration)', () => { + it('should calculate the bucket size for 15 minutes', () => { + const bucketSizeDuration = calculateAuto.near(100, moment.duration(15, 'minutes')); + expect(bucketSizeDuration).not.toBeUndefined(); + expect(isDuration(bucketSizeDuration)).toBeTruthy(); + expect(bucketSizeDuration!.asSeconds()).toBe(10); + }); + it('should calculate the bucket size for an hour', () => { + const bucketSizeDuration = calculateAuto.near(100, moment.duration(1, 'hour')); + expect(bucketSizeDuration).not.toBeUndefined(); + expect(isDuration(bucketSizeDuration)).toBeTruthy(); + expect(bucketSizeDuration!.asSeconds()).toBe(30); + }); + it('should calculate the bucket size for a day', () => { + const bucketSizeDuration = calculateAuto.near(100, moment.duration(1, 'day')); + expect(bucketSizeDuration).not.toBeUndefined(); + expect(isDuration(bucketSizeDuration)).toBeTruthy(); + expect(bucketSizeDuration!.asMinutes()).toBe(10); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_auto.ts b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_auto.ts new file mode 100644 index 0000000000000..00f321b25016d --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_auto.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment, { isDuration, Duration } from 'moment'; +import { isNumber } from 'lodash'; +const d = moment.duration; + +const roundingRules = [ + [d(500, 'ms'), d(100, 'ms')], + [d(5, 'second'), d(1, 'second')], + [d(7.5, 'second'), d(5, 'second')], + [d(15, 'second'), d(10, 'second')], + [d(45, 'second'), d(30, 'second')], + [d(3, 'minute'), d(1, 'minute')], + [d(9, 'minute'), d(5, 'minute')], + [d(20, 'minute'), d(10, 'minute')], + [d(45, 'minute'), d(30, 'minute')], + [d(2, 'hour'), d(1, 'hour')], + [d(6, 'hour'), d(3, 'hour')], + [d(24, 'hour'), d(12, 'hour')], + [d(1, 'week'), d(1, 'd')], + [d(3, 'week'), d(1, 'week')], + [d(1, 'year'), d(1, 'month')], + [Infinity, d(1, 'year')], +]; + +const revRoundingRules = [...roundingRules].reverse(); + +type NumberOrDuration = number | Duration; + +type Rule = NumberOrDuration[]; + +type CheckFunction = ( + bound: NumberOrDuration, + interval: Duration, + target: number +) => Duration | undefined; + +function findRule(rules: Rule[], check: CheckFunction, last?: boolean) { + function pickInterval(buckets: number, duration: Duration) { + const target = duration.asMilliseconds() / buckets; + let lastResult = null; + + for (const rule of rules) { + const result = check(rule[0] as Duration, rule[1] as Duration, target); + + if (result == null) { + if (!last) continue; + if (lastResult) return lastResult; + break; + } + + if (!last) return result; + lastResult = result; + } + + // fallback to just a number of milliseconds, ensure ms is >= 1 + const ms = Math.max(Math.floor(target), 1); + return moment.duration(ms, 'ms'); + } + + return (buckets: number, duration: Duration) => { + const interval = pickInterval(buckets, duration); + if (isDuration(interval)) return interval; + }; +} + +export const calculateAuto = { + near: findRule( + revRoundingRules, + function near(bound, interval, target) { + if (isDuration(bound) && bound.asMilliseconds() > target) return interval; + if (isNumber(bound) && bound > target) return interval; + }, + true + ), + + lessThan: findRule(revRoundingRules, function lessThan(_bound, interval, target) { + if (interval.asMilliseconds() < target) return interval; + }), + + atLeast: findRule(revRoundingRules, function atLeast(_bound, interval, target) { + if (interval.asMilliseconds() <= target) return interval; + }), +}; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_bucket_size.test.ts b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_bucket_size.test.ts new file mode 100644 index 0000000000000..bc54593f10cb9 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/calculate_bucket_size.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { calculateBucketSize } from './'; +import moment from 'moment'; + +const timerange = { + from: moment('2017-01-01T00:00:00.000Z').valueOf(), + to: moment('2017-01-01T01:00:00.000Z').valueOf(), + interval: '1m', + field: '@timetsamp', +}; + +describe('calculateBucketSize(timerange, intervalString)', () => { + test('returns auto calculated buckets', () => { + const result = calculateBucketSize({ ...timerange, interval: 'auto' }); + expect(result).toHaveProperty('bucketSize', 30); + expect(result).toHaveProperty('intervalString', '30s'); + }); + + test('returns overridden buckets (1s)', () => { + const result = calculateBucketSize({ ...timerange, interval: '1s' }); + expect(result).toHaveProperty('bucketSize', 1); + expect(result).toHaveProperty('intervalString', '1s'); + }); + + test('returns overridden buckets (10m)', () => { + const result = calculateBucketSize({ ...timerange, interval: '10m' }); + expect(result).toHaveProperty('bucketSize', 600); + expect(result).toHaveProperty('intervalString', '10m'); + }); + + test('returns overridden buckets (1d)', () => { + const result = calculateBucketSize({ ...timerange, interval: '1d' }); + expect(result).toHaveProperty('bucketSize', 86400); + expect(result).toHaveProperty('intervalString', '1d'); + }); + + test('returns overridden buckets (>=2d)', () => { + const result = calculateBucketSize({ ...timerange, interval: '>=2d' }); + expect(result).toHaveProperty('bucketSize', 86400 * 2); + expect(result).toHaveProperty('intervalString', '2d'); + }); + + test('returns overridden buckets (>=10s)', () => { + const result = calculateBucketSize({ ...timerange, interval: '>=10s' }); + expect(result).toHaveProperty('bucketSize', 30); + expect(result).toHaveProperty('intervalString', '30s'); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/index.ts b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/index.ts new file mode 100644 index 0000000000000..62e4aed6d4049 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/index.ts @@ -0,0 +1,89 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import moment from 'moment'; +import { MetricsAPITimerange } from '../../../../../common/http_api'; +import { calculateAuto } from './calculate_auto'; +import { + getUnitValue, + parseInterval, + convertIntervalToUnit, + ASCENDING_UNIT_ORDER, +} from './unit_to_seconds'; +import { INTERVAL_STRING_RE, GTE_INTERVAL_RE } from './interval_regex'; + +const calculateBucketData = (intervalString: string) => { + const intervalStringMatch = intervalString.match(INTERVAL_STRING_RE); + + if (!intervalStringMatch) { + throw new Error('Unable to parse interval string'); + } + + const parsedInterval = parseInterval(intervalString); + + if (!parsedInterval) { + throw new Error('Unable to parse interval string'); + } + + let bucketSize = Number(intervalStringMatch[1]) * getUnitValue(intervalStringMatch[2]); + + // don't go too small + if (bucketSize < 1) { + bucketSize = 1; + } + + // Check decimal + if (parsedInterval.value && parsedInterval.value % 1 !== 0) { + if (parsedInterval.unit && parsedInterval.unit !== 'ms') { + const { value, unit } = convertIntervalToUnit( + intervalString, + ASCENDING_UNIT_ORDER[ASCENDING_UNIT_ORDER.indexOf(parsedInterval.unit) - 1] + ); + + if (value && unit) { + intervalString = value + unit; + } else { + intervalString = '1ms'; + } + } else { + intervalString = '1ms'; + } + } + + return { + bucketSize, + intervalString, + }; +}; + +const calculateBucketSizeForAutoInterval = (timerange: MetricsAPITimerange): number | undefined => { + const duration = moment.duration(timerange.to - timerange.from, 'ms'); + const bucketSizeDuration = calculateAuto.near(100, duration); + if (bucketSizeDuration) { + return bucketSizeDuration.asSeconds(); + } +}; + +export const calculateBucketSize = (timerange: MetricsAPITimerange) => { + const bucketSize = calculateBucketSizeForAutoInterval(timerange); + let intervalString = `${bucketSize}s`; + + const gteAutoMatch = timerange.interval.match(GTE_INTERVAL_RE); + + if (gteAutoMatch) { + const bucketData = calculateBucketData(gteAutoMatch[1]); + if (bucketSize && bucketData.bucketSize >= bucketSize) { + return bucketData; + } + } + + const matches = timerange.interval.match(INTERVAL_STRING_RE); + if (matches) { + intervalString = timerange.interval; + } + + return calculateBucketData(intervalString); +}; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/interval_regex.test.ts b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/interval_regex.test.ts new file mode 100644 index 0000000000000..6c2e9f8d62e36 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/interval_regex.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GTE_INTERVAL_RE, INTERVAL_STRING_RE } from './interval_regex'; + +describe('REGEX for Intervals', () => { + describe('GTE_INTERVAL_RE', () => { + test('returns true for">=12h"', () => { + const value = GTE_INTERVAL_RE.test('>=12h'); + + expect(value).toBeTruthy(); + }); + test('returns true for ">=1y"', () => { + const value = GTE_INTERVAL_RE.test('>=12h'); + + expect(value).toBeTruthy(); + }); + test('returns true for ">=25m"', () => { + const value = GTE_INTERVAL_RE.test('>=12h'); + + expect(value).toBeTruthy(); + }); + test('returns false "auto"', () => { + const value = GTE_INTERVAL_RE.test('auto'); + + expect(value).toBeFalsy(); + }); + test('returns false "wrongInput"', () => { + const value = GTE_INTERVAL_RE.test('wrongInput'); + + expect(value).toBeFalsy(); + }); + test('returns false "d"', () => { + const value = GTE_INTERVAL_RE.test('d'); + + expect(value).toBeFalsy(); + }); + + test('returns false "y"', () => { + const value = GTE_INTERVAL_RE.test('y'); + + expect(value).toBeFalsy(); + }); + }); + + describe('INTERVAL_STRING_RE', () => { + test('returns true for "8d"', () => { + const value = INTERVAL_STRING_RE.test('8d'); + + expect(value).toBeTruthy(); + }); + test('returns true for "1y"', () => { + const value = INTERVAL_STRING_RE.test('1y'); + + expect(value).toBeTruthy(); + }); + test('returns true for "6M"', () => { + const value = INTERVAL_STRING_RE.test('6M'); + + expect(value).toBeTruthy(); + }); + test('returns false "auto"', () => { + const value = INTERVAL_STRING_RE.test('auto'); + + expect(value).toBeFalsy(); + }); + test('returns false "wrongInput"', () => { + const value = INTERVAL_STRING_RE.test('wrongInput'); + + expect(value).toBeFalsy(); + }); + test('returns false for">=21h"', () => { + const value = INTERVAL_STRING_RE.test('>=21h'); + + expect(value).toBeFalsy(); + }); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/interval_regex.ts b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/interval_regex.ts new file mode 100644 index 0000000000000..d86603551fea2 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/interval_regex.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import dateMath from '@elastic/datemath'; + +export const GTE_INTERVAL_RE = new RegExp(`^>=([\\d\\.]+\\s*(${dateMath.units.join('|')}))$`); +export const INTERVAL_STRING_RE = new RegExp(`^([\\d\\.]+)\\s*(${dateMath.units.join('|')})$`); diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/unit_to_seconds.test.ts b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/unit_to_seconds.test.ts new file mode 100644 index 0000000000000..68816f935a4a0 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/unit_to_seconds.test.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + getUnitValue, + parseInterval, + convertIntervalToUnit, + getSuitableUnit, +} from './unit_to_seconds'; + +describe('parseInterval()', () => { + test('should parse "1m" interval (positive)', () => + expect(parseInterval('1m')).toEqual({ + value: 1, + unit: 'm', + })); + + test('should parse "134d" interval (positive)', () => + expect(parseInterval('134d')).toEqual({ + value: 134, + unit: 'd', + })); + + test('should parse "0.5d" interval (positive)', () => + expect(parseInterval('0.5d')).toEqual({ + value: 0.5, + unit: 'd', + })); + + test('should parse "30M" interval (positive)', () => + expect(parseInterval('30M')).toEqual({ + value: 30, + unit: 'M', + })); + + test('should not parse "gm" interval (negative)', () => + expect(parseInterval('gm')).toEqual({ + value: undefined, + unit: undefined, + })); + + test('should not parse "-1d" interval (negative)', () => + expect(parseInterval('-1d')).toEqual({ + value: undefined, + unit: undefined, + })); + + test('should not parse "M" interval (negative)', () => + expect(parseInterval('M')).toEqual({ + value: undefined, + unit: undefined, + })); +}); + +describe('convertIntervalToUnit()', () => { + test('should convert "30m" interval to "h" unit (positive)', () => + expect(convertIntervalToUnit('30m', 'h')).toEqual({ + value: 0.5, + unit: 'h', + })); + + test('should convert "0.5h" interval to "m" unit (positive)', () => + expect(convertIntervalToUnit('0.5h', 'm')).toEqual({ + value: 30, + unit: 'm', + })); + + test('should convert "1h" interval to "m" unit (positive)', () => + expect(convertIntervalToUnit('1h', 'm')).toEqual({ + value: 60, + unit: 'm', + })); + + test('should convert "1h" interval to "ms" unit (positive)', () => + expect(convertIntervalToUnit('1h', 'ms')).toEqual({ + value: 3600000, + unit: 'ms', + })); + + test('should not convert "30m" interval to "0" unit (positive)', () => + expect(convertIntervalToUnit('30m', 'o')).toEqual({ + value: undefined, + unit: undefined, + })); + + test('should not convert "m" interval to "s" unit (positive)', () => + expect(convertIntervalToUnit('m', 's')).toEqual({ + value: undefined, + unit: undefined, + })); +}); + +describe('getSuitableUnit()', () => { + test('should return "d" unit for oneDayInSeconds (positive)', () => { + const oneDayInSeconds = getUnitValue('d') * 1; + + expect(getSuitableUnit(oneDayInSeconds)).toBe('d'); + }); + + test('should return "d" unit for twoDaysInSeconds (positive)', () => { + const twoDaysInSeconds = getUnitValue('d') * 2; + + expect(getSuitableUnit(twoDaysInSeconds)).toBe('d'); + }); + + test('should return "w" unit for threeWeeksInSeconds (positive)', () => { + const threeWeeksInSeconds = getUnitValue('w') * 3; + + expect(getSuitableUnit(threeWeeksInSeconds)).toBe('w'); + }); + + test('should return "y" unit for aroundOneYearInSeconds (positive)', () => { + const aroundOneYearInSeconds = getUnitValue('d') * 370; + + expect(getSuitableUnit(aroundOneYearInSeconds)).toBe('y'); + }); + + test('should return "y" unit for twoYearsInSeconds (positive)', () => { + const twoYearsInSeconds = getUnitValue('y') * 2; + + expect(getSuitableUnit(twoYearsInSeconds)).toBe('y'); + }); + + test('should return "undefined" unit for negativeNumber (negative)', () => { + const negativeNumber = -12; + + expect(getSuitableUnit(negativeNumber)).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/unit_to_seconds.ts b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/unit_to_seconds.ts new file mode 100644 index 0000000000000..7ca4222fe352f --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_bucket_size/unit_to_seconds.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { sortBy, isNumber } from 'lodash'; +import { INTERVAL_STRING_RE } from './interval_regex'; + +export const ASCENDING_UNIT_ORDER = ['ms', 's', 'm', 'h', 'd', 'w', 'M', 'y']; + +const units: Record = { + ms: 0.001, + s: 1, + m: 60, + h: 3600, + d: 86400, + w: 86400 * 7, + M: 86400 * 30, + y: 86400 * 365, +}; + +const sortedUnits = sortBy(Object.keys(units), (key) => units[key]); + +export const parseInterval = (intervalString: string) => { + let value; + let unit; + + if (intervalString) { + const matches = intervalString.match(INTERVAL_STRING_RE); + + if (matches) { + value = Number(matches[1]); + unit = matches[2]; + } + } + + return { value, unit }; +}; + +export const convertIntervalToUnit = (intervalString: string, newUnit: string) => { + const parsedInterval = parseInterval(intervalString); + let value; + let unit; + + if (parsedInterval.unit && parsedInterval.value && units[newUnit]) { + value = Number( + ((parsedInterval.value * units[parsedInterval.unit]) / units[newUnit]).toFixed(2) + ); + unit = newUnit; + } + + return { value, unit }; +}; + +export const getSuitableUnit = (intervalInSeconds: number) => + sortedUnits.find((key, index, array) => { + const nextUnit = array[index + 1]; + const isValidInput = isNumber(intervalInSeconds) && intervalInSeconds > 0; + const isLastItem = index + 1 === array.length; + + return ( + isValidInput && + ((intervalInSeconds >= units[key] && intervalInSeconds < units[nextUnit]) || isLastItem) + ); + }); + +export const getUnitValue = (unit: string) => units[unit]; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/calculate_date_histogram_offset.test.ts b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_date_histogram_offset.test.ts new file mode 100644 index 0000000000000..4fa313eea1852 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_date_histogram_offset.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { calculateDateHistogramOffset } from './calculate_date_histogram_offset'; +import moment from 'moment'; + +describe('calculateDateHistogramOffset(timerange)', () => { + it('should just work', () => { + const timerange = { + from: moment('2020-01-01T00:03:32').valueOf(), + to: moment('2020-01-01T01:03:32').valueOf(), + interval: '1m', + field: '@timestamp', + }; + const offset = calculateDateHistogramOffset(timerange); + expect(offset).toBe('-28s'); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/calculate_date_histogram_offset.ts b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_date_histogram_offset.ts new file mode 100644 index 0000000000000..673747808f5ca --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/calculate_date_histogram_offset.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MetricsAPITimerange } from '../../../../common/http_api'; +import { calculateBucketSize } from './calculate_bucket_size'; + +export const calculateDateHistogramOffset = (timerange: MetricsAPITimerange): string => { + const fromInSeconds = Math.floor(timerange.from / 1000); + const { bucketSize } = calculateBucketSize(timerange); + + // negative offset to align buckets with full intervals (e.g. minutes) + const offset = (fromInSeconds % bucketSize) - bucketSize; + return `${offset}s`; +}; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.test.ts b/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.test.ts new file mode 100644 index 0000000000000..d9de8ae7e61e5 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.test.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MetricsAPIRequest } from '../../../../common/http_api'; +import moment from 'moment'; +import { convertHistogramBucketsToTimeseries } from './convert_histogram_buckets_to_timeseries'; + +const keys = ['example-0']; + +const options: MetricsAPIRequest = { + timerange: { + field: '@timestamp', + from: moment('2020-01-01T00:00:00Z').valueOf(), + to: moment('2020-01-01T01:00:00Z').valueOf(), + interval: '1m', + }, + limit: 9, + indexPattern: 'metrics-*', + metrics: [ + { id: 'metric_0', aggregations: { metric_0: { avg: { field: 'system.cpu.user.pct' } } } }, + ], +}; + +const buckets = [ + { + key: moment('2020-01-01T00:00:00Z').valueOf(), + key_as_string: moment('2020-01-01T00:00:00Z').toISOString(), + doc_count: 1, + metric_0: { value: 1 }, + }, + { + key: moment('2020-01-01T00:00:00Z').add(1, 'minute').valueOf(), + key_as_string: moment('2020-01-01T00:00:00Z').add(1, 'minute').toISOString(), + doc_count: 1, + metric_0: { value: 1 }, + }, + { + key: moment('2020-01-01T00:00:00Z').add(2, 'minute').valueOf(), + key_as_string: moment('2020-01-01T00:00:00Z').add(2, 'minute').toISOString(), + doc_count: 1, + metric_0: { value: 1 }, + }, + { + key: moment('2020-01-01T00:00:00Z').add(2, 'minute').valueOf(), + key_as_string: moment('2020-01-01T00:00:00Z').add(2, 'minute').toISOString(), + doc_count: 1, + metric_0: { value: null }, + }, +]; + +describe('convertHistogramBucketsToTimeseies(keys, options, buckets)', () => { + it('should just work', () => { + expect(convertHistogramBucketsToTimeseries(keys, options, buckets)).toMatchSnapshot(); + }); + it('should drop the last bucket', () => { + expect( + convertHistogramBucketsToTimeseries(keys, { ...options, dropLastBucket: true }, buckets) + ).toMatchSnapshot(); + }); + it('should return empty timeseries for empty metrics', () => { + expect( + convertHistogramBucketsToTimeseries(keys, { ...options, metrics: [] }, buckets) + ).toMatchSnapshot(); + }); + it('should work with normalized_values', () => { + const bucketsWithNormalizedValue = buckets.map((bucket) => { + const value = bucket.metric_0.value; + if (value) { + return { ...bucket, metric_0: { value, normalized_value: value + 1 } }; + } + return bucket; + }); + expect( + convertHistogramBucketsToTimeseries(keys, { ...options }, bucketsWithNormalizedValue) + ).toMatchSnapshot(); + }); + it('should work with percentiles', () => { + const bucketsWithPercentiles = buckets.map((bucket) => { + return { ...bucket, metric_0: { values: { '95.0': 3 } } }; + }); + expect( + convertHistogramBucketsToTimeseries(keys, { ...options }, bucketsWithPercentiles) + ).toMatchSnapshot(); + }); + it('should throw error with multiple percentiles', () => { + const bucketsWithMultiplePercentiles = buckets.map((bucket) => { + return { ...bucket, metric_0: { values: { '95.0': 3, '99.0': 4 } } }; + }); + expect(() => + convertHistogramBucketsToTimeseries(keys, { ...options }, bucketsWithMultiplePercentiles) + ).toThrow(); + }); + it('should work with keyed percentiles', () => { + const bucketsWithKeyedPercentiles = buckets.map((bucket) => { + return { ...bucket, metric_0: { values: [{ key: '99.0', value: 4 }] } }; + }); + expect( + convertHistogramBucketsToTimeseries(keys, { ...options }, bucketsWithKeyedPercentiles) + ).toMatchSnapshot(); + }); + it('should throw error with multiple keyed percentiles', () => { + const bucketsWithMultipleKeyedPercentiles = buckets.map((bucket) => { + return { + ...bucket, + metric_0: { + values: [ + { key: '95.0', value: 3 }, + { key: '99.0', value: 4 }, + ], + }, + }; + }); + expect(() => + convertHistogramBucketsToTimeseries(keys, { ...options }, bucketsWithMultipleKeyedPercentiles) + ).toThrow(); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts b/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts new file mode 100644 index 0000000000000..95e6ece215133 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/convert_histogram_buckets_to_timeseries.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { get, values, first } from 'lodash'; +import { + MetricsAPIRequest, + MetricsAPISeries, + MetricsAPIColumn, + MetricsAPIRow, +} from '../../../../common/http_api/metrics_api'; +import { + HistogramBucket, + MetricValueType, + BasicMetricValueRT, + NormalizedMetricValueRT, + PercentilesTypeRT, + PercentilesKeyedTypeRT, +} from '../types'; +const BASE_COLUMNS = [{ name: 'timestamp', type: 'date' }] as MetricsAPIColumn[]; + +const getValue = (valueObject: string | number | MetricValueType) => { + if (NormalizedMetricValueRT.is(valueObject)) { + return valueObject.normalized_value || valueObject.value; + } + + if (PercentilesTypeRT.is(valueObject)) { + const percentileValues = values(valueObject.values); + if (percentileValues.length > 1) { + throw new Error( + 'Metrics API only supports a single percentile, multiple percentiles should be sent separately' + ); + } + return first(percentileValues) || null; + } + + if (PercentilesKeyedTypeRT.is(valueObject)) { + if (valueObject.values.length > 1) { + throw new Error( + 'Metrics API only supports a single percentile, multiple percentiles should be sent separately' + ); + } + const percentileValue = first(valueObject.values); + return (percentileValue && percentileValue.value) || null; + } + + if (BasicMetricValueRT.is(valueObject)) { + return valueObject.value; + } + + return null; +}; + +const convertBucketsToRows = ( + options: MetricsAPIRequest, + buckets: HistogramBucket[] +): MetricsAPIRow[] => { + return buckets.map((bucket) => { + const ids = options.metrics.map((metric) => metric.id); + const metrics = ids.reduce((acc, id) => { + const valueObject = get(bucket, [id]); + return { ...acc, [id]: getValue(valueObject) }; + }, {} as Record); + return { timestamp: bucket.key as number, ...metrics }; + }); +}; + +export const convertHistogramBucketsToTimeseries = ( + keys: string[], + options: MetricsAPIRequest, + buckets: HistogramBucket[] +): MetricsAPISeries => { + const id = keys.join(':'); + // If there are no metrics then we just return the empty series + // but still maintain the groupings. + if (options.metrics.length === 0) { + return { id, keys, columns: [], rows: [] }; + } + const columns = options.metrics.map((metric) => ({ + name: metric.id, + type: 'number', + })) as MetricsAPIColumn[]; + const allRows = convertBucketsToRows(options, buckets); + const rows = options.dropLastBucket ? allRows.slice(0, allRows.length - 1) : allRows; + return { + id, + keys, + rows, + columns: [...BASE_COLUMNS, ...columns], + }; +}; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.test.ts b/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.test.ts new file mode 100644 index 0000000000000..1353351cde8a2 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.test.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createAggregations } from './create_aggregations'; +import moment from 'moment'; +import { MetricsAPIRequest } from '../../../../common/http_api'; + +const options: MetricsAPIRequest = { + timerange: { + field: '@timestamp', + from: moment('2020-01-01T00:00:00Z').valueOf(), + to: moment('2020-01-01T01:00:00Z').valueOf(), + interval: '>=1m', + }, + limit: 20, + indexPattern: 'metrics-*', + metrics: [ + { id: 'metric_0', aggregations: { metric_0: { avg: { field: 'system.cpu.user.pct' } } } }, + ], +}; + +describe('createAggregations(options)', () => { + it('should return groupings aggregation with groupBy', () => { + const optionsWithGroupBy = { ...options, groupBy: ['host.name'] }; + expect(createAggregations(optionsWithGroupBy)).toMatchSnapshot(); + }); + it('should return just histogram aggregation without groupBy', () => { + expect(createAggregations(options)).toMatchSnapshot(); + }); + it('should return add offset to histogram', () => { + const optionsWithAlignDataToEnd = { + ...options, + timerange: { + ...options.timerange, + from: moment('2020-01-01T00:00:00Z').subtract(28, 'minutes').valueOf(), + to: moment('2020-01-01T01:00:00Z').subtract(28, 'minutes').valueOf(), + }, + alignDataToEnd: true, + }; + expect(createAggregations(optionsWithAlignDataToEnd)).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts b/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts new file mode 100644 index 0000000000000..991e5febfc634 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/create_aggregations.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MetricsAPIRequest } from '../../../../common/http_api/metrics_api'; +import { calculateDateHistogramOffset } from './calculate_date_histogram_offset'; +import { createMetricsAggregations } from './create_metrics_aggregations'; +import { calculateBucketSize } from './calculate_bucket_size'; + +export const createAggregations = (options: MetricsAPIRequest) => { + const { intervalString } = calculateBucketSize(options.timerange); + const histogramAggregation = { + histogram: { + date_histogram: { + field: options.timerange.field, + fixed_interval: intervalString, + offset: options.alignDataToEnd ? calculateDateHistogramOffset(options.timerange) : '0s', + extended_bounds: { + min: options.timerange.from, + max: options.timerange.to, + }, + }, + aggregations: createMetricsAggregations(options), + }, + }; + + if (Array.isArray(options.groupBy) && options.groupBy.length) { + const limit = options.limit || 9; + return { + groupings: { + composite: { + size: limit, + sources: options.groupBy.map((field, index) => ({ + [`groupBy${index}`]: { terms: { field, order: 'asc' } }, + })), + }, + aggs: histogramAggregation, + }, + }; + } + + return histogramAggregation; +}; diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/create_metrics_aggregations.test.ts b/x-pack/plugins/infra/server/lib/metrics/lib/create_metrics_aggregations.test.ts new file mode 100644 index 0000000000000..a18b8fd533c7f --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/create_metrics_aggregations.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MetricsAPIRequest } from '../../../../common/http_api'; +import moment from 'moment'; +import { createMetricsAggregations } from './create_metrics_aggregations'; + +const options: MetricsAPIRequest = { + timerange: { + field: '@timestamp', + from: moment('2020-01-01T00:00:00Z').valueOf(), + to: moment('2020-01-01T01:00:00Z').valueOf(), + interval: '>=1m', + }, + limit: 20, + indexPattern: 'metrics-*', + metrics: [ + { id: 'metric_0', aggregations: { metric_0: { avg: { field: 'system.cpu.user.pct' } } } }, + { + id: 'metric_1', + aggregations: { + metric_1_max: { max: { field: 'system.network.in.bytes' } }, + metric_1: { derivative: { buckets_path: 'metric_1_max', gap_policy: 'skip', unit: '1s' } }, + }, + }, + ], +}; + +describe('createMetricsAggregations(options)', () => { + it('should just work', () => { + expect(createMetricsAggregations(options)).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/infra/server/lib/metrics/lib/create_metrics_aggregations.ts b/x-pack/plugins/infra/server/lib/metrics/lib/create_metrics_aggregations.ts new file mode 100644 index 0000000000000..c1400ea407829 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/lib/create_metrics_aggregations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MetricsUIAggregation } from '../../../../common/inventory_models/types'; +import { MetricsAPIRequest } from '../../../../common/http_api/metrics_api'; + +export const createMetricsAggregations = (options: MetricsAPIRequest): MetricsUIAggregation => { + const { metrics } = options; + return metrics.reduce((aggs, metric) => { + return { ...aggs, ...metric.aggregations }; + }, {}); +}; diff --git a/x-pack/plugins/infra/server/lib/metrics/types.ts b/x-pack/plugins/infra/server/lib/metrics/types.ts new file mode 100644 index 0000000000000..d1866470e0cf9 --- /dev/null +++ b/x-pack/plugins/infra/server/lib/metrics/types.ts @@ -0,0 +1,72 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as rt from 'io-ts'; +import { InfraDatabaseSearchResponse, CallWithRequestParams } from '../adapters/framework'; + +export type ESSearchClient = ( + options: CallWithRequestParams +) => Promise>; + +const NumberOrNullRT = rt.union([rt.number, rt.null]); + +export const BasicMetricValueRT = rt.type({ value: NumberOrNullRT }); + +export const NormalizedMetricValueRT = rt.intersection([ + BasicMetricValueRT, + rt.type({ normalized_value: NumberOrNullRT }), +]); +export const PercentilesTypeRT = rt.type({ values: rt.record(rt.string, NumberOrNullRT) }); + +export const PercentilesKeyedTypeRT = rt.type({ + values: rt.array(rt.type({ key: rt.string, value: NumberOrNullRT })), +}); + +export const MetricValueTypeRT = rt.union([ + BasicMetricValueRT, + NormalizedMetricValueRT, + PercentilesTypeRT, + PercentilesKeyedTypeRT, +]); +export type MetricValueType = rt.TypeOf; + +export const HistogramBucketRT = rt.record( + rt.string, + rt.union([rt.number, rt.string, MetricValueTypeRT]) +); + +export const HistogramResponseRT = rt.type({ + histogram: rt.type({ + buckets: rt.array(HistogramBucketRT), + }), +}); + +const GroupingBucketRT = rt.intersection([ + rt.type({ + key: rt.record(rt.string, rt.string), + doc_count: rt.number, + }), + HistogramResponseRT, +]); + +export const GroupingResponseRT = rt.type({ + groupings: rt.intersection([ + rt.type({ + buckets: rt.array(GroupingBucketRT), + }), + rt.partial({ + after_key: rt.record(rt.string, rt.string), + }), + ]), +}); + +export type HistogramBucket = rt.TypeOf; + +export type HistogramResponse = rt.TypeOf; + +export type GroupingResponse = rt.TypeOf; + +export type MetricsESResponse = HistogramResponse | GroupingResponse; diff --git a/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts b/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts index d1a4ed431a2be..719ffdb8fa7c4 100644 --- a/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts +++ b/x-pack/plugins/infra/server/lib/snapshot/create_timerange_with_interval.ts @@ -8,7 +8,7 @@ import { uniq } from 'lodash'; import { InfraSnapshotRequestOptions } from './types'; import { getMetricsAggregations } from './query_helpers'; import { calculateMetricInterval } from '../../utils/calculate_metric_interval'; -import { SnapshotModel, SnapshotModelMetricAggRT } from '../../../common/inventory_models/types'; +import { MetricsUIAggregation, ESBasicMetricAggRT } from '../../../common/inventory_models/types'; import { getDatasetForField } from '../../routes/metrics_explorer/lib/get_dataset_for_field'; import { InfraTimerangeInput } from '../../../common/http_api/snapshot_api'; import { ESSearchClient } from '.'; @@ -59,12 +59,12 @@ export const createTimeRangeWithInterval = async ( const aggregationsToModules = async ( client: ESSearchClient, - aggregations: SnapshotModel, + aggregations: MetricsUIAggregation, options: InfraSnapshotRequestOptions ): Promise => { const uniqueFields = Object.values(aggregations) .reduce>((fields, agg) => { - if (SnapshotModelMetricAggRT.is(agg)) { + if (ESBasicMetricAggRT.is(agg)) { return uniq(fields.concat(Object.values(agg).map((a) => a?.field))); } return fields; diff --git a/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts b/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts index 732fa10decc98..ca63043ba868e 100644 --- a/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts +++ b/x-pack/plugins/infra/server/lib/snapshot/query_helpers.ts @@ -9,8 +9,8 @@ import { findInventoryModel, findInventoryFields } from '../../../common/invento import { InfraSnapshotRequestOptions } from './types'; import { getIntervalInSeconds } from '../../utils/get_interval_in_seconds'; import { - SnapshotModelRT, - SnapshotModel, + MetricsUIAggregation, + MetricsUIAggregationRT, InventoryItemType, } from '../../../common/inventory_models/types'; import { @@ -75,11 +75,13 @@ export const metricToAggregation = ( return inventoryModel.metrics.snapshot?.[metric.type]; }; -export const getMetricsAggregations = (options: InfraSnapshotRequestOptions): SnapshotModel => { +export const getMetricsAggregations = ( + options: InfraSnapshotRequestOptions +): MetricsUIAggregation => { const { metrics } = options; return metrics.reduce((aggs, metric, index) => { const aggregation = metricToAggregation(options.nodeType, metric, index); - if (!SnapshotModelRT.is(aggregation)) { + if (!MetricsUIAggregationRT.is(aggregation)) { throw new Error( i18n.translate('xpack.infra.snapshot.missingSnapshotMetricError', { defaultMessage: 'The aggregation for {metric} for {nodeType} is not available.', diff --git a/x-pack/plugins/infra/server/lib/sources/defaults.ts b/x-pack/plugins/infra/server/lib/sources/defaults.ts index b096bed84fa9a..82b2852099bae 100644 --- a/x-pack/plugins/infra/server/lib/sources/defaults.ts +++ b/x-pack/plugins/infra/server/lib/sources/defaults.ts @@ -4,20 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { + METRICS_INDEX_PATTERN, + LOGS_INDEX_PATTERN, + TIMESTAMP_FIELD, +} from '../../../common/constants'; import { InfraSourceConfiguration } from '../../../common/http_api/source_api'; export const defaultSourceConfiguration: InfraSourceConfiguration = { name: 'Default', description: '', - metricAlias: 'metrics-*,metricbeat-*', - logAlias: 'logs-*,filebeat-*,kibana_sample_data_logs*', + metricAlias: METRICS_INDEX_PATTERN, + logAlias: LOGS_INDEX_PATTERN, fields: { container: 'container.id', host: 'host.name', message: ['message', '@message'], pod: 'kubernetes.pod.uid', tiebreaker: '_doc', - timestamp: '@timestamp', + timestamp: TIMESTAMP_FIELD, }, inventoryDefaultView: '0', metricsExplorerDefaultView: '0', diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/index.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/index.ts index c22095a31195a..4b90b52cb51f6 100644 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/index.ts +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/index.ts @@ -10,17 +10,23 @@ import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; import { schema } from '@kbn/config-schema'; import { InfraBackendLibs } from '../../lib/infra_types'; -import { getGroupings } from './lib/get_groupings'; -import { populateSeriesWithTSVBData } from './lib/populate_series_with_tsvb_data'; -import { metricsExplorerRequestBodyRT, metricsExplorerResponseRT } from '../../../common/http_api'; +import { + metricsExplorerRequestBodyRT, + metricsExplorerResponseRT, + MetricsExplorerPageInfo, +} from '../../../common/http_api'; import { throwErrors } from '../../../common/runtime_types'; +import { convertRequestToMetricsAPIOptions } from './lib/convert_request_to_metrics_api_options'; +import { createSearchClient } from '../../lib/create_search_client'; +import { findIntervalForMetrics } from './lib/find_interval_for_metrics'; +import { query } from '../../lib/metrics'; +import { queryTotalGroupings } from './lib/query_total_groupings'; +import { transformSeries } from './lib/transform_series'; const escapeHatch = schema.object({}, { unknowns: 'allow' }); export const initMetricExplorerRoute = (libs: InfraBackendLibs) => { const { framework } = libs; - const { callWithRequest } = framework; - framework.registerRoute( { method: 'post', @@ -31,26 +37,48 @@ export const initMetricExplorerRoute = (libs: InfraBackendLibs) => { }, async (requestContext, request, response) => { try { - const payload = pipe( + const options = pipe( metricsExplorerRequestBodyRT.decode(request.body), fold(throwErrors(Boom.badRequest), identity) ); - const search = (searchOptions: object) => - callWithRequest<{}, Aggregation>(requestContext, 'search', searchOptions); + const client = createSearchClient(requestContext, framework); + const interval = await findIntervalForMetrics(client, options); - // First we get the groupings from a composite aggregation - const groupings = await getGroupings(search, payload); + const optionsWithInterval = options.forceInterval + ? options + : { + ...options, + timerange: { + ...options.timerange, + interval: interval ? `>=${interval}s` : options.timerange.interval, + }, + }; + + const metricsApiOptions = convertRequestToMetricsAPIOptions(optionsWithInterval); + const metricsApiResponse = await query(client, metricsApiOptions); + const totalGroupings = await queryTotalGroupings(client, metricsApiOptions); + const hasGroupBy = + Array.isArray(metricsApiOptions.groupBy) && metricsApiOptions.groupBy.length > 0; + + const pageInfo: MetricsExplorerPageInfo = { + total: totalGroupings, + afterKey: null, + }; + + if (metricsApiResponse.info.afterKey) { + pageInfo.afterKey = metricsApiResponse.info.afterKey; + } + + // If we have a groupBy but there are ZERO groupings returned then we need to + // return an empty array. Otherwise we transform the series to match the current schema. + const series = + hasGroupBy && totalGroupings === 0 + ? [] + : metricsApiResponse.series.map(transformSeries(hasGroupBy)); - // Then we take the results and fill in the data from TSVB with the - // user's custom metrics - const seriesWithMetrics = await Promise.all( - groupings.series.map( - populateSeriesWithTSVBData(request, payload, framework, requestContext) - ) - ); return response.ok({ - body: metricsExplorerResponseRT.encode({ ...groupings, series: seriesWithMetrics }), + body: metricsExplorerResponseRT.encode({ series, pageInfo }), }); } catch (error) { return response.internalError({ diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.test.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.test.ts new file mode 100644 index 0000000000000..67dbaf8e1e877 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.test.ts @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { convertMetricToMetricsAPIMetric } from './convert_metric_to_metrics_api_metric'; +import { + MetricsExplorerMetric, + MetricsAPIMetric, + MetricsExplorerAggregation, +} from '../../../../common/http_api'; + +describe('convertMetricToMetricsAPIMetric(metric, index)', () => { + const runTest = (metric: MetricsExplorerMetric, aggregation: MetricsAPIMetric) => + it(`should convert ${metric.aggregation}`, () => { + expect(convertMetricToMetricsAPIMetric(metric, 1)).toEqual(aggregation); + }); + + const runTestForBasic = (aggregation: MetricsExplorerAggregation) => + runTest( + { aggregation, field: 'system.cpu.user.pct' }, + { + id: 'metric_1', + aggregations: { metric_1: { [aggregation]: { field: 'system.cpu.user.pct' } } }, + } + ); + + runTestForBasic('avg'); + runTestForBasic('sum'); + runTestForBasic('max'); + runTestForBasic('min'); + runTestForBasic('cardinality'); + + runTest( + { aggregation: 'rate', field: 'system.network.in.bytes' }, + { + id: 'metric_1', + aggregations: { + metric_1_max: { + max: { + field: 'system.network.in.bytes', + }, + }, + metric_1_deriv: { + derivative: { + buckets_path: 'metric_1_max', + gap_policy: 'skip', + unit: '1s', + }, + }, + metric_1: { + bucket_script: { + buckets_path: { + value: 'metric_1_deriv[normalized_value]', + }, + gap_policy: 'skip', + script: { + lang: 'painless', + source: 'params.value > 0.0 ? params.value : 0.0', + }, + }, + }, + }, + } + ); + + runTest( + { aggregation: 'count' }, + { + id: 'metric_1', + aggregations: { + metric_1: { + bucket_script: { + buckets_path: { + count: '_count', + }, + gap_policy: 'skip', + script: { + lang: 'expression', + source: 'count * 1', + }, + }, + }, + }, + } + ); +}); diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.ts new file mode 100644 index 0000000000000..93948a8b8797e --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_metric_to_metrics_api_metric.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { networkTraffic } from '../../../../common/inventory_models/shared/metrics/snapshot/network_traffic'; +import { MetricsAPIMetric, MetricsExplorerMetric } from '../../../../common/http_api'; + +export const convertMetricToMetricsAPIMetric = ( + metric: MetricsExplorerMetric, + index: number +): MetricsAPIMetric => { + const id = `metric_${index}`; + if (metric.aggregation === 'rate' && metric.field) { + return { + id, + aggregations: networkTraffic(id, metric.field), + }; + } + + if (['p95', 'p99'].includes(metric.aggregation) && metric.field) { + const percent = metric.aggregation === 'p95' ? 95 : 99; + return { + id, + aggregations: { + [id]: { + percentiles: { + field: metric.field, + percents: [percent], + }, + }, + }, + }; + } + + if (['max', 'min', 'avg', 'cardinality', 'sum'].includes(metric.aggregation) && metric.field) { + return { + id, + aggregations: { + [id]: { + [metric.aggregation]: { field: metric.field }, + }, + }, + }; + } + + return { + id, + aggregations: { + [id]: { + bucket_script: { + buckets_path: { count: '_count' }, + script: { + source: 'count * 1', + lang: 'expression', + }, + gap_policy: 'skip', + }, + }, + }, + }; +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.test.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.test.ts new file mode 100644 index 0000000000000..4c423aee347e9 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.test.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MetricsExplorerRequestBody, MetricsAPIRequest } from '../../../../common/http_api'; +import { convertRequestToMetricsAPIOptions } from './convert_request_to_metrics_api_options'; + +const BASE_REQUEST: MetricsExplorerRequestBody = { + timerange: { + field: '@timestamp', + from: new Date('2020-01-01T00:00:00Z').getTime(), + to: new Date('2020-01-01T01:00:00Z').getTime(), + interval: '1m', + }, + limit: 9, + indexPattern: 'metrics-*', + metrics: [{ aggregation: 'avg', field: 'system.cpu.user.pct' }], +}; + +const BASE_METRICS_UI_OPTIONS: MetricsAPIRequest = { + timerange: { + field: '@timestamp', + from: new Date('2020-01-01T00:00:00Z').getTime(), + to: new Date('2020-01-01T01:00:00Z').getTime(), + interval: '1m', + }, + limit: 9, + dropLastBucket: true, + indexPattern: 'metrics-*', + metrics: [ + { id: 'metric_0', aggregations: { metric_0: { avg: { field: 'system.cpu.user.pct' } } } }, + ], +}; + +describe('convertRequestToMetricsAPIOptions', () => { + it('should just work', () => { + expect(convertRequestToMetricsAPIOptions(BASE_REQUEST)).toEqual(BASE_METRICS_UI_OPTIONS); + }); + + it('should work with string afterKeys', () => { + expect(convertRequestToMetricsAPIOptions({ ...BASE_REQUEST, afterKey: 'host.name' })).toEqual({ + ...BASE_METRICS_UI_OPTIONS, + afterKey: { groupBy0: 'host.name' }, + }); + }); + + it('should work with afterKey objects', () => { + const afterKey = { groupBy0: 'host.name', groupBy1: 'cloud.availability_zone' }; + expect( + convertRequestToMetricsAPIOptions({ + ...BASE_REQUEST, + afterKey, + }) + ).toEqual({ + ...BASE_METRICS_UI_OPTIONS, + afterKey, + }); + }); + + it('should work with string group bys', () => { + expect( + convertRequestToMetricsAPIOptions({ + ...BASE_REQUEST, + groupBy: 'host.name', + }) + ).toEqual({ + ...BASE_METRICS_UI_OPTIONS, + groupBy: ['host.name'], + }); + }); + + it('should work with group by arrays', () => { + expect( + convertRequestToMetricsAPIOptions({ + ...BASE_REQUEST, + groupBy: ['host.name', 'cloud.availability_zone'], + }) + ).toEqual({ + ...BASE_METRICS_UI_OPTIONS, + groupBy: ['host.name', 'cloud.availability_zone'], + }); + }); + + it('should work with filterQuery json string', () => { + const filter = { bool: { filter: [{ match: { 'host.name': 'example-01' } }] } }; + expect( + convertRequestToMetricsAPIOptions({ + ...BASE_REQUEST, + filterQuery: JSON.stringify(filter), + }) + ).toEqual({ + ...BASE_METRICS_UI_OPTIONS, + filters: [filter], + }); + }); + + it('should work with filterQuery as Lucene expressions', () => { + const filter = `host.name: 'example-01'`; + expect( + convertRequestToMetricsAPIOptions({ + ...BASE_REQUEST, + filterQuery: filter, + }) + ).toEqual({ + ...BASE_METRICS_UI_OPTIONS, + filters: [{ query_string: { query: filter, analyze_wildcard: true } }], + }); + }); + + it('should work with empty metrics', () => { + expect( + convertRequestToMetricsAPIOptions({ + ...BASE_REQUEST, + metrics: [], + }) + ).toEqual({ + ...BASE_METRICS_UI_OPTIONS, + metrics: [], + }); + }); +}); diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.ts new file mode 100644 index 0000000000000..2dd00c4aed59c --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/convert_request_to_metrics_api_options.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isObject, isArray } from 'lodash'; +import { + MetricsAPIRequest, + MetricsExplorerRequestBody, + afterKeyObjectRT, +} from '../../../../common/http_api'; +import { convertMetricToMetricsAPIMetric } from './convert_metric_to_metrics_api_metric'; + +export const convertRequestToMetricsAPIOptions = ( + options: MetricsExplorerRequestBody +): MetricsAPIRequest => { + const metrics = options.metrics.map(convertMetricToMetricsAPIMetric); + const { limit, timerange, indexPattern } = options; + + const metricsApiOptions: MetricsAPIRequest = { + timerange, + indexPattern, + limit, + metrics, + dropLastBucket: true, + }; + + if (options.afterKey) { + metricsApiOptions.afterKey = afterKeyObjectRT.is(options.afterKey) + ? options.afterKey + : { groupBy0: options.afterKey }; + } + + if (options.groupBy) { + metricsApiOptions.groupBy = isArray(options.groupBy) ? options.groupBy : [options.groupBy]; + } + + if (options.filterQuery) { + try { + const filterObject = JSON.parse(options.filterQuery); + if (isObject(filterObject)) { + metricsApiOptions.filters = [filterObject]; + } + } catch (err) { + metricsApiOptions.filters = [ + { + query_string: { + query: options.filterQuery, + analyze_wildcard: true, + }, + }, + ]; + } + } + + return metricsApiOptions; +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts deleted file mode 100644 index 3a9abf525a9f0..0000000000000 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/create_metrics_model.ts +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { MetricsExplorerRequestBody } from '../../../../common/http_api/metrics_explorer'; -import { TSVBMetricModel } from '../../../../common/inventory_models/types'; - -const percentileToVaue = (agg: 'p95' | 'p99') => { - if (agg === 'p95') { - return 95; - } - return 99; -}; - -export const createMetricModel = (options: MetricsExplorerRequestBody): TSVBMetricModel => { - // if dropLastBucket is set use the value otherwise default to true. - const dropLastBucket: boolean = options.dropLastBucket != null ? options.dropLastBucket : true; - return { - id: 'custom', - requires: [], - index_pattern: options.indexPattern, - interval: options.timerange.interval, - time_field: options.timerange.field, - drop_last_bucket: dropLastBucket, - type: 'timeseries', - // Create one series per metric requested. The series.id will be used to identify the metric - // when the responses are processed and combined with the grouping request. - series: options.metrics.map((metric, index) => { - // If the metric is a rate then we need to add TSVB metrics for calculating the derivative - if (metric.aggregation === 'rate') { - const aggType = 'max'; - return { - id: `metric_${index}`, - split_mode: 'everything', - metrics: [ - { - id: `metric_${aggType}_${index}`, - field: metric.field, - type: aggType, - }, - { - id: `metric_deriv_${aggType}_${index}`, - field: `metric_${aggType}_${index}`, - type: 'derivative', - unit: '1s', - }, - { - id: `metric_posonly_deriv_${aggType}_${index}`, - type: 'calculation', - variables: [ - { id: 'var-rate', name: 'rate', field: `metric_deriv_${aggType}_${index}` }, - ], - script: 'params.rate > 0.0 ? params.rate : 0.0', - }, - ], - }; - } - - if (metric.aggregation === 'p95' || metric.aggregation === 'p99') { - return { - id: `metric_${index}`, - split_mode: 'everything', - metrics: [ - { - field: metric.field, - id: `metric_${metric.aggregation}_${index}`, - type: 'percentile', - percentiles: [ - { - id: 'percentile_0', - value: percentileToVaue(metric.aggregation), - }, - ], - }, - ], - }; - } - - // Create a basic TSVB series with a single metric - const aggregation = metric.aggregation || 'avg'; - - return { - id: `metric_${index}`, - split_mode: 'everything', - metrics: [ - { - field: metric.field, - id: `metric_${aggregation}_${index}`, - type: aggregation, - }, - ], - }; - }), - }; -}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts new file mode 100644 index 0000000000000..876bbb4199441 --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/find_interval_for_metrics.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { uniq } from 'lodash'; +import LRU from 'lru-cache'; +import { MetricsExplorerRequestBody } from '../../../../common/http_api'; +import { ESSearchClient } from '../../../lib/snapshot'; +import { getDatasetForField } from './get_dataset_for_field'; +import { calculateMetricInterval } from '../../../utils/calculate_metric_interval'; + +const cache = new LRU({ + max: 100, + maxAge: 15 * 60 * 1000, +}); + +export const findIntervalForMetrics = async ( + client: ESSearchClient, + options: MetricsExplorerRequestBody +) => { + const fields = uniq( + options.metrics.map((metric) => (metric.field ? metric.field : null)).filter((f) => f) + ) as string[]; + + const cacheKey = fields.sort().join(':'); + + if (cache.has(cacheKey)) return cache.get(cacheKey); + + if (fields.length === 0) { + return 60; + } + + const modules = await Promise.all( + fields.map( + async (field) => await getDatasetForField(client, field as string, options.indexPattern) + ) + ); + + const interval = calculateMetricInterval( + client, + { + indexPattern: options.indexPattern, + timestampField: options.timerange.field, + timerange: options.timerange, + }, + modules.filter(Boolean) as string[] + ); + cache.set(cacheKey, interval); + return interval; +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_groupings.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_groupings.ts deleted file mode 100644 index fdecb5f3d9315..0000000000000 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/get_groupings.ts +++ /dev/null @@ -1,150 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { set } from '@elastic/safer-lodash-set'; -import { isObject } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { InfraDatabaseSearchResponse } from '../../../lib/adapters/framework'; -import { - MetricsExplorerRequestBody, - MetricsExplorerResponse, - afterKeyObjectRT, -} from '../../../../common/http_api/metrics_explorer'; - -interface GroupingAggregation { - groupingsCount: { - value: number; - }; - groupings: { - after_key?: { - [name: string]: string; - }; - buckets: Array<{ key: { [id: string]: string }; doc_count: number }>; - }; -} - -const EMPTY_RESPONSE = { - series: [ - { - id: i18n.translate('xpack.infra.metricsExploer.everything', { defaultMessage: 'Everything' }), - columns: [], - rows: [], - }, - ], - pageInfo: { total: 0, afterKey: null }, -}; - -export const getGroupings = async ( - search: (options: object) => Promise>, - options: MetricsExplorerRequestBody -): Promise => { - if (!options.groupBy) { - return EMPTY_RESPONSE; - } - - if (Array.isArray(options.groupBy) && options.groupBy.length === 0) { - return EMPTY_RESPONSE; - } - - const limit = options.limit || 9; - const groupBy = Array.isArray(options.groupBy) ? options.groupBy : [options.groupBy]; - const filter: Array> = [ - { - range: { - [options.timerange.field]: { - gte: options.timerange.from, - lte: options.timerange.to, - format: 'epoch_millis', - }, - }, - }, - ...groupBy.map((field) => ({ exists: { field } })), - ]; - const params = { - allowNoIndices: true, - ignoreUnavailable: true, - index: options.indexPattern, - body: { - size: 0, - query: { - bool: { - should: [ - ...options.metrics - .filter((m) => m.field) - .map((m) => ({ - exists: { field: m.field }, - })), - ], - filter, - }, - }, - aggs: { - groupingsCount: { - cardinality: { - script: { source: groupBy.map((field) => `doc['${field}'].value`).join('+') }, - }, - }, - groupings: { - composite: { - size: limit, - sources: groupBy.map((field, index) => ({ - [`groupBy${index}`]: { terms: { field, order: 'asc' } }, - })), - }, - }, - }, - }, - }; - - if (params.body.query.bool.should.length !== 0) { - set(params, 'body.query.bool.minimum_should_match', 1); - } - - if (options.afterKey) { - if (afterKeyObjectRT.is(options.afterKey)) { - set(params, 'body.aggs.groupings.composite.after', options.afterKey); - } else { - set(params, 'body.aggs.groupings.composite.after', { groupBy0: options.afterKey }); - } - } - - if (options.filterQuery) { - try { - const filterObject = JSON.parse(options.filterQuery); - if (isObject(filterObject)) { - params.body.query.bool.filter.push(filterObject); - } - } catch (err) { - params.body.query.bool.filter.push({ - query_string: { - query: options.filterQuery, - analyze_wildcard: true, - }, - }); - } - } - - const response = await search(params); - if (response.hits.total.value === 0) { - return { ...EMPTY_RESPONSE, series: [] }; - } - if (!response.aggregations) { - throw new Error('Aggregations should be present.'); - } - const { groupings, groupingsCount } = response.aggregations; - const { after_key: afterKey } = groupings; - return { - series: groupings.buckets.map((bucket) => { - const keys = Object.values(bucket.key); - const id = keys.join(' / '); - return { id, keys, rows: [], columns: [] }; - }), - pageInfo: { - total: groupingsCount.value, - afterKey: afterKey && groupings.buckets.length === limit ? afterKey : null, - }, - }; -}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts deleted file mode 100644 index ce4a9c71b74e6..0000000000000 --- a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/populate_series_with_tsvb_data.ts +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { union, uniq, isArray, isString } from 'lodash'; -import { KibanaRequest, RequestHandlerContext } from 'src/core/server'; -import { KibanaFramework } from '../../../lib/adapters/framework/kibana_framework_adapter'; -import { - MetricsExplorerRow, - MetricsExplorerSeries, - MetricsExplorerRequestBody, - MetricsExplorerColumn, -} from '../../../../common/http_api/metrics_explorer'; -import { createMetricModel } from './create_metrics_model'; -import { JsonObject } from '../../../../common/typed_json'; -import { calculateMetricInterval } from '../../../utils/calculate_metric_interval'; -import { getDatasetForField } from './get_dataset_for_field'; -import { - CallWithRequestParams, - InfraDatabaseSearchResponse, -} from '../../../lib/adapters/framework'; - -export const populateSeriesWithTSVBData = ( - request: KibanaRequest, - options: MetricsExplorerRequestBody, - framework: KibanaFramework, - requestContext: RequestHandlerContext -) => async (series: MetricsExplorerSeries) => { - // IF there are no metrics selected then we should return an empty result. - if (options.metrics.length === 0) { - return { - ...series, - columns: [], - rows: [], - }; - } - - // Set the filter for the group by or match everything - const isGroupBySet = - Array.isArray(options.groupBy) && options.groupBy.length - ? true - : isString(options.groupBy) - ? true - : false; - - const filters: JsonObject[] = isGroupBySet - ? isArray(options.groupBy) - ? options.groupBy - .filter((f) => f) - .map((field, index) => ({ match: { [field as string]: series.keys?.[index] || '' } })) - : [{ match: { [options.groupBy as string]: series.id } }] - : []; - - if (options.filterQuery) { - try { - const filterQuery = JSON.parse(options.filterQuery); - filters.push(filterQuery); - } catch (error) { - filters.push({ - query_string: { - query: options.filterQuery, - analyze_wildcard: true, - }, - }); - } - } - const timerange = { min: options.timerange.from, max: options.timerange.to }; - - const client = ( - opts: CallWithRequestParams - ): Promise> => - framework.callWithRequest(requestContext, 'search', opts); - - // Create the TSVB model based on the request options - const model = createMetricModel(options); - const modules = await Promise.all( - uniq(options.metrics.filter((m) => m.field)).map( - async (m) => await getDatasetForField(client, m.field as string, options.indexPattern) - ) - ); - - const calculatedInterval = await calculateMetricInterval( - client, - { - indexPattern: options.indexPattern, - timestampField: options.timerange.field, - timerange: options.timerange, - }, - modules.filter((m) => m) as string[] - ); - - if (calculatedInterval) { - model.interval = options.forceInterval - ? options.timerange.interval - : `>=${calculatedInterval}s`; - } - - // Get TSVB results using the model, timerange and filters - const tsvbResults = await framework.makeTSVBRequest( - requestContext, - request, - model, - timerange, - filters - ); - - // If there is no data `custom` will not exist. - if (!tsvbResults.custom) { - return { - ...series, - columns: [], - rows: [], - }; - } - - // Setup the dynamic columns and row attributes depending on if the user is doing a group by - // and multiple metrics - const attributeColumns: MetricsExplorerColumn[] = - options.groupBy != null ? [{ name: 'groupBy', type: 'string' }] : []; - const metricColumns: MetricsExplorerColumn[] = options.metrics.map((m, i) => ({ - name: `metric_${i}`, - type: 'number', - })); - const rowAttributes = options.groupBy != null ? { groupBy: series.id } : {}; - - // To support multiple metrics, there are multiple TSVB series which need to be combined - // into one MetricExplorerRow (Canvas row). This is done by collecting all the timestamps - // across each TSVB series. Then for each timestamp we find the values and create a - // MetricsExplorerRow. - const timestamps = tsvbResults.custom.series.reduce( - (currentTimestamps, tsvbSeries) => - union( - currentTimestamps, - tsvbSeries.data.map((row) => row[0]) - ).sort(), - [] as number[] - ); - // Combine the TSVB series for multiple metrics. - const rows = timestamps.map((timestamp) => { - return tsvbResults.custom.series.reduce( - (currentRow, tsvbSeries) => { - const matches = tsvbSeries.data.find((d) => d[0] === timestamp); - if (matches) { - return { ...currentRow, [tsvbSeries.id]: matches[1] }; - } - return currentRow; - }, - { timestamp, ...rowAttributes } as MetricsExplorerRow - ); - }); - return { - ...series, - rows, - columns: [ - { name: 'timestamp', type: 'date' } as MetricsExplorerColumn, - ...metricColumns, - ...attributeColumns, - ], - }; -}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts new file mode 100644 index 0000000000000..25b956e093d0f --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/query_total_groupings.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { isArray } from 'lodash'; +import { MetricsAPIRequest } from '../../../../common/http_api'; +import { ESSearchClient } from '../../../lib/metrics/types'; + +interface GroupingResponse { + count: { + value: number; + }; +} + +export const queryTotalGroupings = async ( + client: ESSearchClient, + options: MetricsAPIRequest +): Promise => { + if (!options.groupBy || (isArray(options.groupBy) && options.groupBy.length === 0)) { + return Promise.resolve(0); + } + + const params = { + allowNoIndices: true, + ignoreUnavailable: true, + index: options.indexPattern, + body: { + size: 0, + query: { + bool: { + filter: [ + { + range: { + [options.timerange.field]: { + gte: options.timerange.from, + lte: options.timerange.to, + format: 'epoch_millis', + }, + }, + }, + ...options.groupBy.map((field) => ({ exists: { field } })), + ], + }, + }, + aggs: { + count: { + cardinality: { + script: options.groupBy.map((field) => `doc['${field}'].value`).join('+'), + }, + }, + }, + }, + }; + + const response = await client<{}, GroupingResponse>(params); + return response.aggregations?.count.value ?? 0; +}; diff --git a/x-pack/plugins/infra/server/routes/metrics_explorer/lib/transform_series.ts b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/transform_series.ts new file mode 100644 index 0000000000000..227ce5c36c2dc --- /dev/null +++ b/x-pack/plugins/infra/server/routes/metrics_explorer/lib/transform_series.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MetricsAPISeries, MetricsExplorerSeries } from '../../../../common/http_api'; + +export const transformSeries = (hasGroupBy: boolean) => ( + series: MetricsAPISeries +): MetricsExplorerSeries => { + const id = series.keys?.join(' / ') ?? series.id; + return { + ...series, + id, + rows: series.rows.map((row) => { + if (hasGroupBy) { + return { ...row, groupBy: id }; + } + return row; + }), + columns: hasGroupBy ? [...series.columns, { name: 'groupBy', type: 'string' }] : series.columns, + }; +}; diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 526f2d88574d3..5f52bfe981440 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -8891,7 +8891,7 @@ "xpack.infra.metrics.missingTSVBModelError": "{nodeType}では{metricId}のTSVBモデルが存在しません", "xpack.infra.metrics.pluginTitle": "メトリック", "xpack.infra.metrics.refetchButtonLabel": "新規データを確認", - "xpack.infra.metricsExploer.everything": "すべて", + "xpack.infra.metricsExplorer.everything": "すべて", "xpack.infra.metricsExplorer.actionsLabel.aria": "{grouping} のアクション", "xpack.infra.metricsExplorer.actionsLabel.button": "アクション", "xpack.infra.metricsExplorer.aggregationLabel": "/", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index af5e68b7e44d7..a97de80243ded 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -8894,7 +8894,7 @@ "xpack.infra.metrics.missingTSVBModelError": "{nodeType} 的 {metricId} TSVB 模型不存在", "xpack.infra.metrics.pluginTitle": "指标", "xpack.infra.metrics.refetchButtonLabel": "检查新数据", - "xpack.infra.metricsExploer.everything": "所有内容", + "xpack.infra.metricsExplorer.everything": "所有内容", "xpack.infra.metricsExplorer.actionsLabel.aria": "适用于 {grouping} 的操作", "xpack.infra.metricsExplorer.actionsLabel.button": "操作", "xpack.infra.metricsExplorer.aggregationLabel": "的", diff --git a/x-pack/test/api_integration/apis/metrics_ui/metrics_explorer.ts b/x-pack/test/api_integration/apis/metrics_ui/metrics_explorer.ts index d372496d2d1d9..16809fba8c8df 100644 --- a/x-pack/test/api_integration/apis/metrics_ui/metrics_explorer.ts +++ b/x-pack/test/api_integration/apis/metrics_ui/metrics_explorer.ts @@ -50,7 +50,7 @@ export default function ({ getService }: FtrProviderContext) { const body = decodeOrThrow(metricsExplorerResponseRT)(response.body); expect(body.series).length(1); const firstSeries = first(body.series) as any; - expect(firstSeries).to.have.property('id', 'Everything'); + expect(firstSeries).to.have.property('id', '*'); expect(firstSeries.columns).to.eql([ { name: 'timestamp', type: 'date' }, { name: 'metric_0', type: 'number' }, @@ -90,7 +90,7 @@ export default function ({ getService }: FtrProviderContext) { const body = decodeOrThrow(metricsExplorerResponseRT)(response.body); expect(body.series).length(1); const firstSeries = first(body.series) as any; - expect(firstSeries).to.have.property('id', 'Everything'); + expect(firstSeries).to.have.property('id', '*'); expect(firstSeries.columns).to.eql([ { name: 'timestamp', type: 'date' }, { name: 'metric_0', type: 'number' }, @@ -121,7 +121,7 @@ export default function ({ getService }: FtrProviderContext) { const body = decodeOrThrow(metricsExplorerResponseRT)(response.body); expect(body.series).length(1); const firstSeries = first(body.series) as any; - expect(firstSeries).to.have.property('id', 'Everything'); + expect(firstSeries).to.have.property('id', '*'); expect(firstSeries.columns).to.eql([]); expect(firstSeries.rows).to.have.length(0); }); From 4d6592edc8ad2b82f1a0eec98aeae810b63b1d73 Mon Sep 17 00:00:00 2001 From: Tim Roes Date: Fri, 14 Aug 2020 09:17:43 +0200 Subject: [PATCH 07/51] Revert "attempt excluding a codeowners directory" (#75023) This reverts commit 250a0b17b03f8924462d484c2254a5af7d64f1ff. --- .github/CODEOWNERS | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5a8271302a72b..1f076e3c84001 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -118,7 +118,6 @@ # Operations /src/dev/ @elastic/kibana-operations -!/src/dev/i18n/ @elastic/kibana-operations /src/setup_node_env/ @elastic/kibana-operations /src/optimize/ @elastic/kibana-operations /packages/*eslint*/ @elastic/kibana-operations From 7cf0e49c897512a827de870c3504888b29824499 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Fri, 14 Aug 2020 09:45:02 +0200 Subject: [PATCH 08/51] [Uptime] Singular alert (#74659) Co-authored-by: Elastic Machine --- .../translations/translations/ja-JP.json | 10 - .../translations/translations/zh-CN.json | 10 - .../uptime/common/runtime_types/ping/ping.ts | 6 + x-pack/plugins/uptime/common/translations.ts | 17 + .../__snapshots__/monitor_list.test.tsx.snap | 20 + .../monitor_list_drawer.test.tsx.snap | 5 + .../__tests__/monitor_status.test.ts | 4 +- .../public/lib/alert_types/monitor_status.tsx | 2 +- .../public/lib/alert_types/translations.ts | 13 - .../lib/alerts/__tests__/status_check.test.ts | 819 ++++++++++-------- .../server/lib/alerts/duration_anomaly.ts | 2 +- .../uptime/server/lib/alerts/status_check.ts | 609 +++++-------- .../uptime/server/lib/alerts/translations.ts | 77 ++ .../plugins/uptime/server/lib/alerts/types.ts | 2 +- .../server/lib/alerts/uptime_alert_wrapper.ts | 34 + .../uptime/server/lib/compose/kibana.ts | 14 +- .../get_monitor_availability.test.ts | 192 ++-- .../__tests__/get_monitor_status.test.ts | 148 ++-- .../lib/requests/get_monitor_availability.ts | 15 +- .../server/lib/requests/get_monitor_status.ts | 46 +- .../uptime/server/lib/requests/index.ts | 49 +- .../server/lib/requests/uptime_requests.ts | 57 -- 22 files changed, 1128 insertions(+), 1023 deletions(-) create mode 100644 x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts delete mode 100644 x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 5f52bfe981440..8c92e7359b2f7 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -18868,10 +18868,6 @@ "xpack.uptime.alerts.anomaly.criteriaExpression.description": "監視するとき", "xpack.uptime.alerts.anomaly.scoreExpression.ariaLabel": "異常アラートしきい値の条件を表示する式。", "xpack.uptime.alerts.anomaly.scoreExpression.description": "異常と重要度があります", - "xpack.uptime.alerts.availability.emptyMessage": "可用性しきい値({threshold} %)未満のモニターはありません", - "xpack.uptime.alerts.availability.monitorSummary": "{nameOrId}({url}): {availabilityRatio}%", - "xpack.uptime.alerts.availability.multiItemTitle": "可用性しきい値({threshold} %)未満の上位{monitorCount}個のモニター:\n", - "xpack.uptime.alerts.availability.singleItemTitle": "可用性しきい値({threshold} %)未満のモニター:\n", "xpack.uptime.alerts.durationAnomaly": "アップタイム期間異常", "xpack.uptime.alerts.durationAnomaly.actionVariables.state.anomalyStartTimestamp": "異常の開始のISO8601タイムスタンプ", "xpack.uptime.alerts.durationAnomaly.actionVariables.state.expectedResponseTime": "想定応答時間", @@ -18884,11 +18880,6 @@ "xpack.uptime.alerts.durationAnomaly.actionVariables.state.slowestAnomalyResponse": "単位(ミリ秒、秒)が関連付けられた異常バケット中の最も遅い応答時間。", "xpack.uptime.alerts.durationAnomaly.clientName": "アップタイム期間異常", "xpack.uptime.alerts.durationAnomaly.defaultActionMessage": "{anomalyStartTimestamp}に、{monitor}、url {monitorUrl}で異常({severity}レベル)応答時間が検出されました。異常重要度スコアは{severityScore}です。\n位置情報{observerLocation}から高い応答時間{slowestAnomalyResponse}が検出されました。想定された応答時間は{expectedResponseTime}です。", - "xpack.uptime.alerts.message.emptyTitle": "停止状況監視 ID を受信していません。", - "xpack.uptime.alerts.message.fullListOverflow": "... とその他 {overflowCount} {pluralizedMonitor}", - "xpack.uptime.alerts.message.multipleTitle": "停止状況監視: ", - "xpack.uptime.alerts.message.overflowBody": "... とその他 {overflowCount} 監視", - "xpack.uptime.alerts.message.singularTitle": "停止状況監視: ", "xpack.uptime.alerts.monitorStatus": "稼働状況の監視ステータス", "xpack.uptime.alerts.monitorStatus.actionVariables.context.downMonitorsWithGeo.description": "アラートによって「ダウン」と検知された一部またはすべてのモニターを示す、生成された概要。", "xpack.uptime.alerts.monitorStatus.actionVariables.context.message.description": "現在ダウンしているモニターを要約する生成されたメッセージ。", @@ -18915,7 +18906,6 @@ "xpack.uptime.alerts.monitorStatus.availability.unit.headline": "時間範囲単位を選択します", "xpack.uptime.alerts.monitorStatus.availability.unit.selectable": "この選択を使用して、このアラートの可用性範囲単位を設定", "xpack.uptime.alerts.monitorStatus.clientName": "稼働状況の監視ステータス", - "xpack.uptime.alerts.monitorStatus.defaultActionMessage": "{contextMessage}\n前回トリガー日時:{lastTriggered}\n", "xpack.uptime.alerts.monitorStatus.filterBar.ariaLabel": "監視状態アラートのフィルター基準を許可するインプット", "xpack.uptime.alerts.monitorStatus.filters.anyLocation": "任意の場所", "xpack.uptime.alerts.monitorStatus.filters.anyPort": "任意のポート", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index a97de80243ded..5ab70ff7a9d04 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -18876,10 +18876,6 @@ "xpack.uptime.alerts.anomaly.criteriaExpression.description": "当监测", "xpack.uptime.alerts.anomaly.scoreExpression.ariaLabel": "显示异常告警阈值的条件的表达式。", "xpack.uptime.alerts.anomaly.scoreExpression.description": "具有异常,严重性为", - "xpack.uptime.alerts.availability.emptyMessage": "没有监测低于可用性阈值 ({threshold} %)", - "xpack.uptime.alerts.availability.monitorSummary": "{nameOrId}({url}):{availabilityRatio}%", - "xpack.uptime.alerts.availability.multiItemTitle": "低于可用性阈值 ({threshold} %) 的排名前 {monitorCount} 监测:\n", - "xpack.uptime.alerts.availability.singleItemTitle": "低于可用性阈值 ({threshold} %) 的监测:\n", "xpack.uptime.alerts.durationAnomaly": "Uptime 持续时间异常", "xpack.uptime.alerts.durationAnomaly.actionVariables.state.anomalyStartTimestamp": "异常开始的 ISO8601 时间戳。", "xpack.uptime.alerts.durationAnomaly.actionVariables.state.expectedResponseTime": "预期响应时间", @@ -18892,11 +18888,6 @@ "xpack.uptime.alerts.durationAnomaly.actionVariables.state.slowestAnomalyResponse": "在附加单位(ms、s)的异常存储桶期间最慢的响应时间。", "xpack.uptime.alerts.durationAnomaly.clientName": "Uptime 持续时间异常", "xpack.uptime.alerts.durationAnomaly.defaultActionMessage": "{anomalyStartTimestamp} 在 url {monitorUrl} 的 {monitor} 上检测到异常({severity} 级别)响应时间。异常严重性分数为 {severityScore}。\n从位置 {observerLocation} 检测到高达 {slowestAnomalyResponse} 的响应时间。预期响应时间为 {expectedResponseTime}。", - "xpack.uptime.alerts.message.emptyTitle": "未接收到已关闭监测 ID", - "xpack.uptime.alerts.message.fullListOverflow": "...以及 {overflowCount} 个其他{pluralizedMonitor}", - "xpack.uptime.alerts.message.multipleTitle": "已关闭监测: ", - "xpack.uptime.alerts.message.overflowBody": "... 以及 {overflowCount} 个其他监测", - "xpack.uptime.alerts.message.singularTitle": "已关闭监测: ", "xpack.uptime.alerts.monitorStatus": "运行时间监测状态", "xpack.uptime.alerts.monitorStatus.actionVariables.context.downMonitorsWithGeo.description": "生成的摘要,显示告警已检测为“关闭”的部分或所有监测", "xpack.uptime.alerts.monitorStatus.actionVariables.context.message.description": "生成的消息,汇总当前关闭的监测", @@ -18923,7 +18914,6 @@ "xpack.uptime.alerts.monitorStatus.availability.unit.headline": "选择时间范围单位", "xpack.uptime.alerts.monitorStatus.availability.unit.selectable": "使用此选择来设置此告警的可用性范围单位", "xpack.uptime.alerts.monitorStatus.clientName": "运行时间监测状态", - "xpack.uptime.alerts.monitorStatus.defaultActionMessage": "{contextMessage}\n上次触发时间:{lastTriggered}\n", "xpack.uptime.alerts.monitorStatus.filterBar.ariaLabel": "允许对监测状态告警使用筛选条件的输入", "xpack.uptime.alerts.monitorStatus.filters.anyLocation": "任意位置", "xpack.uptime.alerts.monitorStatus.filters.anyPort": "任意端口", diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index 0a4d6310927c4..f954f8ba30849 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -214,6 +214,9 @@ export const makePing = (f: { ip?: string; status?: string; duration?: number; + location?: string; + name?: string; + url?: string; }): Ping => { return { docId: f.docId || 'myDocId', @@ -224,7 +227,10 @@ export const makePing = (f: { ip: f.ip || '127.0.0.1', status: f.status || 'up', duration: { us: f.duration || 100000 }, + name: f.name, }, + ...(f.location ? { observer: { geo: { name: f.location } } } : {}), + ...(f.url ? { url: { full: f.url } } : {}), }; }; diff --git a/x-pack/plugins/uptime/common/translations.ts b/x-pack/plugins/uptime/common/translations.ts index 81f46df86f02e..a4a20a1445f57 100644 --- a/x-pack/plugins/uptime/common/translations.ts +++ b/x-pack/plugins/uptime/common/translations.ts @@ -16,3 +16,20 @@ export const VALUE_MUST_BE_GREATER_THAN_ZERO = i18n.translate( export const VALUE_MUST_BE_AN_INTEGER = i18n.translate('xpack.uptime.settings.invalid.nanError', { defaultMessage: 'Value must be an integer.', }); + +export const MonitorStatusTranslations = { + defaultActionMessage: i18n.translate('xpack.uptime.alerts.monitorStatus.defaultActionMessage', { + defaultMessage: + 'Monitor {monitorName} with url {monitorUrl} is {statusMessage} from {observerLocation}. The latest error message is {latestErrorMessage}', + values: { + monitorName: '{{state.monitorName}}', + monitorUrl: '{{{state.monitorUrl}}}', + statusMessage: '{{state.statusMessage}}', + latestErrorMessage: '{{{state.latestErrorMessage}}}', + observerLocation: '{{state.observerLocation}}', + }, + }), + name: i18n.translate('xpack.uptime.alerts.monitorStatus.clientName', { + defaultMessage: 'Uptime monitor status', + }), +}; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap index 42ac821c10c7a..2ae8454b99893 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/__tests__/__snapshots__/monitor_list.test.tsx.snap @@ -213,6 +213,7 @@ exports[`MonitorList component MonitorListPagination component renders the pagin }, "id": "foo", "ip": "127.0.0.1", + "name": undefined, "status": "up", "type": "icmp", }, @@ -226,6 +227,7 @@ exports[`MonitorList component MonitorListPagination component renders the pagin }, "id": "foo", "ip": "127.0.0.2", + "name": undefined, "status": "up", "type": "icmp", }, @@ -239,6 +241,7 @@ exports[`MonitorList component MonitorListPagination component renders the pagin }, "id": "foo", "ip": "127.0.0.3", + "name": undefined, "status": "down", "type": "icmp", }, @@ -266,6 +269,7 @@ exports[`MonitorList component MonitorListPagination component renders the pagin }, "id": "bar", "ip": "127.0.0.1", + "name": undefined, "status": "down", "type": "icmp", }, @@ -279,6 +283,7 @@ exports[`MonitorList component MonitorListPagination component renders the pagin }, "id": "bar", "ip": "127.0.0.1", + "name": undefined, "status": "down", "type": "icmp", }, @@ -516,6 +521,7 @@ exports[`MonitorList component renders error list 1`] = ` }, "id": "foo", "ip": "127.0.0.1", + "name": undefined, "status": "up", "type": "icmp", }, @@ -529,6 +535,7 @@ exports[`MonitorList component renders error list 1`] = ` }, "id": "foo", "ip": "127.0.0.2", + "name": undefined, "status": "up", "type": "icmp", }, @@ -542,6 +549,7 @@ exports[`MonitorList component renders error list 1`] = ` }, "id": "foo", "ip": "127.0.0.3", + "name": undefined, "status": "down", "type": "icmp", }, @@ -569,6 +577,7 @@ exports[`MonitorList component renders error list 1`] = ` }, "id": "bar", "ip": "127.0.0.1", + "name": undefined, "status": "down", "type": "icmp", }, @@ -582,6 +591,7 @@ exports[`MonitorList component renders error list 1`] = ` }, "id": "bar", "ip": "127.0.0.1", + "name": undefined, "status": "down", "type": "icmp", }, @@ -714,6 +724,7 @@ exports[`MonitorList component renders loading state 1`] = ` }, "id": "foo", "ip": "127.0.0.1", + "name": undefined, "status": "up", "type": "icmp", }, @@ -727,6 +738,7 @@ exports[`MonitorList component renders loading state 1`] = ` }, "id": "foo", "ip": "127.0.0.2", + "name": undefined, "status": "up", "type": "icmp", }, @@ -740,6 +752,7 @@ exports[`MonitorList component renders loading state 1`] = ` }, "id": "foo", "ip": "127.0.0.3", + "name": undefined, "status": "down", "type": "icmp", }, @@ -767,6 +780,7 @@ exports[`MonitorList component renders loading state 1`] = ` }, "id": "bar", "ip": "127.0.0.1", + "name": undefined, "status": "down", "type": "icmp", }, @@ -780,6 +794,7 @@ exports[`MonitorList component renders loading state 1`] = ` }, "id": "bar", "ip": "127.0.0.1", + "name": undefined, "status": "down", "type": "icmp", }, @@ -1611,6 +1626,7 @@ exports[`MonitorList component shallow renders the monitor list 1`] = ` }, "id": "foo", "ip": "127.0.0.1", + "name": undefined, "status": "up", "type": "icmp", }, @@ -1624,6 +1640,7 @@ exports[`MonitorList component shallow renders the monitor list 1`] = ` }, "id": "foo", "ip": "127.0.0.2", + "name": undefined, "status": "up", "type": "icmp", }, @@ -1637,6 +1654,7 @@ exports[`MonitorList component shallow renders the monitor list 1`] = ` }, "id": "foo", "ip": "127.0.0.3", + "name": undefined, "status": "down", "type": "icmp", }, @@ -1664,6 +1682,7 @@ exports[`MonitorList component shallow renders the monitor list 1`] = ` }, "id": "bar", "ip": "127.0.0.1", + "name": undefined, "status": "down", "type": "icmp", }, @@ -1677,6 +1696,7 @@ exports[`MonitorList component shallow renders the monitor list 1`] = ` }, "id": "bar", "ip": "127.0.0.1", + "name": undefined, "status": "down", "type": "icmp", }, diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap index 329fb8bade106..42c885dfaf515 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/monitor_list_drawer/__tests__/__snapshots__/monitor_list_drawer.test.tsx.snap @@ -113,6 +113,7 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there are }, "id": "foo", "ip": "127.0.0.1", + "name": undefined, "status": "up", "type": "icmp", }, @@ -126,6 +127,7 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there are }, "id": "foo", "ip": "127.0.0.1", + "name": undefined, "status": "down", "type": "icmp", }, @@ -139,6 +141,7 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there are }, "id": "foo", "ip": "127.0.0.2", + "name": undefined, "status": "up", "type": "icmp", }, @@ -152,6 +155,7 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there are }, "id": "foo", "ip": "127.0.0.3", + "name": undefined, "status": "down", "type": "icmp", }, @@ -284,6 +288,7 @@ exports[`MonitorListDrawer component renders a MonitorListDrawer when there is o }, "id": "foo", "ip": "127.0.0.1", + "name": undefined, "status": "up", "type": "icmp", }, diff --git a/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts b/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts index e999768d4e55d..6af817c82ad95 100644 --- a/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts +++ b/x-pack/plugins/uptime/public/lib/alert_types/__tests__/monitor_status.test.ts @@ -202,9 +202,7 @@ describe('monitor status alert type', () => { ).toMatchInlineSnapshot(` Object { "alertParamsExpression": [Function], - "defaultActionMessage": "{{context.message}} - Last triggered at: {{state.lastTriggeredAt}} - ", + "defaultActionMessage": "Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}}", "iconClass": "uptimeApp", "id": "xpack.uptime.alerts.monitorStatus", "name": { "certExpirationThreshold": 30, "heartbeatIndices": "heartbeat-8*", }, + "filters": undefined, "locations": Array [], "numTimes": 5, - "shouldCheckStatus": true, "timerange": Object { "from": "now-15m", "to": "now", @@ -114,19 +114,28 @@ describe('status check alert', () => { it('triggers when monitors are down and provides expected state', async () => { toISOStringSpy.mockImplementation(() => 'foo date string'); - const mockGetter = jest.fn(); + const mockGetter: jest.Mock = jest.fn(); + mockGetter.mockReturnValue([ { - monitor_id: 'first', + monitorId: 'first', location: 'harrisburg', count: 234, status: 'down', + monitorInfo: makePing({ + id: 'first', + location: 'harrisburg', + }), }, { - monitor_id: 'first', + monitorId: 'first', location: 'fairbanks', count: 234, status: 'down', + monitorInfo: makePing({ + id: 'first', + location: 'fairbanks', + }), }, ]); const { server, libs, plugins } = bootstrapDependencies({ getMonitorStatus: mockGetter }); @@ -136,7 +145,7 @@ describe('status check alert', () => { // @ts-ignore the executor can return `void`, but ours never does const state: Record = await alert.executor(options); expect(mockGetter).toHaveBeenCalledTimes(1); - expect(alertServices.alertInstanceFactory).toHaveBeenCalledTimes(1); + expect(alertServices.alertInstanceFactory).toHaveBeenCalledTimes(2); expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -146,9 +155,9 @@ describe('status check alert', () => { "certExpirationThreshold": 30, "heartbeatIndices": "heartbeat-8*", }, + "filters": undefined, "locations": Array [], "numTimes": 5, - "shouldCheckStatus": true, "timerange": Object { "from": "now-15m", "to": "now", @@ -157,7 +166,7 @@ describe('status check alert', () => { ] `); const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; - expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(2); expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -168,30 +177,23 @@ describe('status check alert', () => { "lastCheckedAt": "foo date string", "lastResolvedAt": undefined, "lastTriggeredAt": "foo date string", - "monitors": Array [ - Object { - "count": 234, - "location": "fairbanks", - "monitor_id": "first", - "status": "down", - }, - Object { - "count": 234, - "location": "harrisburg", - "monitor_id": "first", - "status": "down", - }, - ], + "latestErrorMessage": undefined, + "monitorId": "first", + "monitorName": "first", + "monitorType": "myType", + "monitorUrl": undefined, + "observerHostname": undefined, + "observerLocation": "harrisburg", + "statusMessage": "down", }, ] `); - expect(alertInstanceMock.scheduleActions).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledTimes(2); expect(alertInstanceMock.scheduleActions.mock.calls[0]).toMatchInlineSnapshot(` Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { - "downMonitorsWithGeo": "first from fairbanks; first from harrisburg; ", - "message": "Down monitor: first", + "message": "Monitor first with url is down from harrisburg. The latest error message is ", }, ] `); @@ -199,19 +201,29 @@ describe('status check alert', () => { it('supports 7.7 alert format', async () => { toISOStringSpy.mockImplementation(() => '7.7 date'); - const mockGetter = jest.fn(); + const mockGetter: jest.Mock = jest.fn(); + mockGetter.mockReturnValue([ { - monitor_id: 'first', + monitorId: 'first', location: 'harrisburg', count: 234, status: 'down', + monitorInfo: makePing({ + id: 'first', + location: 'harrisburg', + }), }, { - monitor_id: 'first', + monitorId: 'first', location: 'fairbanks', count: 234, status: 'down', + + monitorInfo: makePing({ + id: 'first', + location: 'fairbanks', + }), }, ]); const { server, libs, plugins } = bootstrapDependencies({ @@ -227,8 +239,9 @@ describe('status check alert', () => { }); const alertServices: AlertServicesMock = options.services; const state = await alert.executor(options); + const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; - expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(2); expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -239,20 +252,14 @@ describe('status check alert', () => { "lastCheckedAt": "7.7 date", "lastResolvedAt": undefined, "lastTriggeredAt": "7.7 date", - "monitors": Array [ - Object { - "count": 234, - "location": "fairbanks", - "monitor_id": "first", - "status": "down", - }, - Object { - "count": 234, - "location": "harrisburg", - "monitor_id": "first", - "status": "down", - }, - ], + "latestErrorMessage": undefined, + "monitorId": "first", + "monitorName": "first", + "monitorType": "myType", + "monitorUrl": undefined, + "observerHostname": undefined, + "observerLocation": "harrisburg", + "statusMessage": "down", }, ] `); @@ -272,19 +279,28 @@ describe('status check alert', () => { it('supports 7.8 alert format', async () => { expect.assertions(5); toISOStringSpy.mockImplementation(() => 'foo date string'); - const mockGetter = jest.fn(); + const mockGetter: jest.Mock = jest.fn(); mockGetter.mockReturnValue([ { - monitor_id: 'first', + monitorId: 'first', location: 'harrisburg', count: 234, status: 'down', + monitorInfo: makePing({ + id: 'first', + location: 'harrisburg', + }), }, { - monitor_id: 'first', + monitorId: 'first', location: 'fairbanks', count: 234, status: 'down', + + monitorInfo: makePing({ + id: 'first', + location: 'fairbanks', + }), }, ]); const { server, libs, plugins } = bootstrapDependencies({ @@ -317,7 +333,166 @@ describe('status check alert', () => { "certExpirationThreshold": 30, "heartbeatIndices": "heartbeat-8*", }, - "filters": "{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":12349}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":5601}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"url.port\\":443}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"observer.geo.name\\":\\"harrisburg\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"monitor.type\\":\\"http\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"unsecured\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"tags\\":\\"containers\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"match_phrase\\":{\\"tags\\":\\"org:google\\"}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}],\\"minimum_should_match\\":1}}]}}]}}]}},{\\"bool\\":{\\"should\\":[{\\"exists\\":{\\"field\\":\\"monitor.ip\\"}}],\\"minimum_should_match\\":1}}]}}", + "filters": Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "url.port": 12349, + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "url.port": 5601, + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "url.port": 443, + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "observer.geo.name": "harrisburg", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "monitor.type": "http", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "tags": "unsecured", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "tags": "containers", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match_phrase": Object { + "tags": "org:google", + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "exists": Object { + "field": "monitor.ip", + }, + }, + ], + }, + }, + ], + }, + }, "locations": Array [], "numTimes": 3, "timerange": Object { @@ -327,7 +502,7 @@ describe('status check alert', () => { }, ] `); - expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(2); expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -338,20 +513,14 @@ describe('status check alert', () => { "lastCheckedAt": "foo date string", "lastResolvedAt": undefined, "lastTriggeredAt": "foo date string", - "monitors": Array [ - Object { - "count": 234, - "location": "fairbanks", - "monitor_id": "first", - "status": "down", - }, - Object { - "count": 234, - "location": "harrisburg", - "monitor_id": "first", - "status": "down", - }, - ], + "latestErrorMessage": undefined, + "monitorId": "first", + "monitorName": "first", + "monitorType": "myType", + "monitorUrl": undefined, + "observerHostname": undefined, + "observerLocation": "harrisburg", + "statusMessage": "down", }, ] `); @@ -390,6 +559,7 @@ describe('status check alert', () => { search: 'url.full: *', }); await alert.executor(options); + expect(mockGetter).toHaveBeenCalledTimes(1); expect(mockGetter.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -400,7 +570,36 @@ describe('status check alert', () => { "certExpirationThreshold": 30, "heartbeatIndices": "heartbeat-8*", }, - "filters": "{\\"bool\\":{\\"filter\\":[{\\"bool\\":{\\"should\\":[{\\"match\\":{\\"monitor.type\\":\\"http\\"}}],\\"minimum_should_match\\":1}},{\\"bool\\":{\\"should\\":[{\\"exists\\":{\\"field\\":\\"url.full\\"}}],\\"minimum_should_match\\":1}}]}}", + "filters": Object { + "bool": Object { + "filter": Array [ + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "match": Object { + "monitor.type": "http", + }, + }, + ], + }, + }, + Object { + "bool": Object { + "minimum_should_match": 1, + "should": Array [ + Object { + "exists": Object { + "field": "url.full", + }, + }, + ], + }, + }, + ], + }, + }, "locations": Array [], "numTimes": 20, "timerange": Object { @@ -415,57 +614,60 @@ describe('status check alert', () => { it('supports availability checks', async () => { expect.assertions(8); toISOStringSpy.mockImplementation(() => 'availability test'); - const mockGetter = jest.fn(); - mockGetter.mockReturnValue([ - { - monitor_id: 'first', - location: 'harrisburg', - count: 234, - status: 'down', - }, - { - monitor_id: 'first', - location: 'fairbanks', - count: 234, - status: 'down', - }, - ]); - const mockAvailability = jest.fn(); + const mockGetter: jest.Mock = jest.fn(); + mockGetter.mockReturnValue([]); + const mockAvailability: jest.Mock = jest.fn(); mockAvailability.mockReturnValue([ { monitorId: 'foo', location: 'harrisburg', - name: 'Foo', - url: 'https://foo.com', up: 2341, down: 17, availabilityRatio: 0.992790500424088, + monitorInfo: makePing({ + id: 'foo', + location: 'harrisburg', + name: 'Foo', + url: 'https://foo.com', + }), }, { monitorId: 'foo', location: 'fairbanks', - name: 'Foo', - url: 'https://foo.com', up: 2343, down: 47, availabilityRatio: 0.980334728033473, + monitorInfo: makePing({ + id: 'foo', + location: 'fairbanks', + name: 'Foo', + url: 'https://foo.com', + }), }, { monitorId: 'unreliable', location: 'fairbanks', - name: 'Unreliable', - url: 'https://unreliable.co', up: 2134, down: 213, availabilityRatio: 0.909245845760545, + monitorInfo: makePing({ + id: 'unreliable', + location: 'fairbanks', + name: 'Unreliable', + url: 'https://unreliable.co', + }), }, { monitorId: 'no-name', location: 'fairbanks', - url: 'https://no-name.co', up: 2134, down: 213, availabilityRatio: 0.909245845760545, + monitorInfo: makePing({ + id: 'no-name', + location: 'fairbanks', + url: 'https://no-name.co', + }), }, ]); const { server, libs, plugins } = bootstrapDependencies({ @@ -487,11 +689,12 @@ describe('status check alert', () => { tags: ['unsecured', 'containers', 'org:google'], }, shouldCheckAvailability: true, + shouldCheckStatus: false, }); const alertServices: AlertServicesMock = options.services; const state = await alert.executor(options); const [{ value: alertInstanceMock }] = alertServices.alertInstanceFactory.mock.results; - expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.replaceState).toHaveBeenCalledTimes(4); expect(alertInstanceMock.replaceState.mock.calls[0]).toMatchInlineSnapshot(` Array [ Object { @@ -502,22 +705,42 @@ describe('status check alert', () => { "lastCheckedAt": "availability test", "lastResolvedAt": undefined, "lastTriggeredAt": "availability test", - "monitors": Array [], + "latestErrorMessage": undefined, + "monitorId": "foo", + "monitorName": "Foo", + "monitorType": "myType", + "monitorUrl": "https://foo.com", + "observerHostname": undefined, + "observerLocation": "harrisburg", + "statusMessage": "below threshold with 99.28% availability expected is 99.34%", }, ] `); - expect(alertInstanceMock.scheduleActions).toHaveBeenCalledTimes(1); + expect(alertInstanceMock.scheduleActions).toHaveBeenCalledTimes(4); expect(alertInstanceMock.scheduleActions.mock.calls).toMatchInlineSnapshot(` Array [ Array [ "xpack.uptime.alerts.actionGroups.monitorStatus", Object { - "downMonitorsWithGeo": "", - "message": "Top 3 Monitors Below Availability Threshold (99.34 %): - Unreliable(https://unreliable.co): 90.925% - no-name(https://no-name.co): 90.925% - Foo(https://foo.com): 98.033% - ", + "message": "Monitor Foo with url https://foo.com is below threshold with 99.28% availability expected is 99.34% from harrisburg. The latest error message is ", + }, + ], + Array [ + "xpack.uptime.alerts.actionGroups.monitorStatus", + Object { + "message": "Monitor Foo with url https://foo.com is below threshold with 98.03% availability expected is 99.34% from fairbanks. The latest error message is ", + }, + ], + Array [ + "xpack.uptime.alerts.actionGroups.monitorStatus", + Object { + "message": "Monitor Unreliable with url https://unreliable.co is below threshold with 90.92% availability expected is 99.34% from fairbanks. The latest error message is ", + }, + ], + Array [ + "xpack.uptime.alerts.actionGroups.monitorStatus", + Object { + "message": "Monitor no-name with url https://no-name.co is below threshold with 90.92% availability expected is 99.34% from fairbanks. The latest error message is ", }, ], ] @@ -573,7 +796,9 @@ describe('status check alert', () => { }, search: 'ur.port: *', shouldCheckAvailability: true, + shouldCheckStatus: false, }); + await alert.executor(options); expect(mockAvailability).toHaveBeenCalledTimes(1); expect(mockAvailability.mock.calls[0]).toMatchInlineSnapshot(` @@ -613,8 +838,11 @@ describe('status check alert', () => { threshold: '90', }, shouldCheckAvailability: true, + shouldCheckStatus: false, }); + await alert.executor(options); + expect(mockAvailability).toHaveBeenCalledTimes(1); expect(mockAvailability.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -635,116 +863,6 @@ describe('status check alert', () => { }); }); - describe('fullListByIdAndLocation', () => { - it('renders a list of all monitors', () => { - const statuses: GetMonitorStatusResult[] = [ - { - location: 'harrisburg', - monitor_id: 'first', - status: 'down', - count: 34, - }, - { - location: 'fairbanks', - monitor_id: 'second', - status: 'down', - count: 23, - }, - { - location: 'fairbanks', - monitor_id: 'first', - status: 'down', - count: 23, - }, - { - location: 'harrisburg', - monitor_id: 'second', - status: 'down', - count: 34, - }, - ]; - expect(fullListByIdAndLocation(statuses)).toMatchInlineSnapshot( - `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; "` - ); - }); - - it('renders a list of monitors when greater than limit', () => { - const statuses: GetMonitorStatusResult[] = [ - { - location: 'fairbanks', - monitor_id: 'second', - status: 'down', - count: 23, - }, - { - location: 'fairbanks', - monitor_id: 'first', - status: 'down', - count: 23, - }, - { - location: 'harrisburg', - monitor_id: 'first', - status: 'down', - count: 34, - }, - { - location: 'harrisburg', - monitor_id: 'second', - status: 'down', - count: 34, - }, - ]; - expect(fullListByIdAndLocation(statuses.slice(0, 2), 1)).toMatchInlineSnapshot( - `"first from fairbanks; ...and 1 other monitor/location"` - ); - }); - - it('renders expected list of monitors when limit difference > 1', () => { - const statuses: GetMonitorStatusResult[] = [ - { - location: 'fairbanks', - monitor_id: 'second', - status: 'down', - count: 23, - }, - { - location: 'harrisburg', - monitor_id: 'first', - status: 'down', - count: 34, - }, - { - location: 'harrisburg', - monitor_id: 'second', - status: 'down', - count: 34, - }, - { - location: 'harrisburg', - monitor_id: 'third', - status: 'down', - count: 34, - }, - { - location: 'fairbanks', - monitor_id: 'third', - status: 'down', - count: 23, - }, - { - location: 'fairbanks', - monitor_id: 'first', - status: 'down', - count: 23, - }, - ]; - expect(fullListByIdAndLocation(statuses, 4)).toMatchInlineSnapshot( - `"first from fairbanks; first from harrisburg; second from fairbanks; second from harrisburg; ...and 2 other monitors/locations"` - ); - }); - }); - describe('alert factory', () => { let alert: AlertType; @@ -820,16 +938,26 @@ describe('status check alert', () => { mockGetIndexPattern.mockReturnValue(undefined); it('returns `undefined` for no filters or search', async () => { - expect(await generateFilterDSL(mockGetIndexPattern)).toBeUndefined(); + expect( + await generateFilterDSL( + mockGetIndexPattern, + { 'monitor.type': [], 'observer.geo.name': [], tags: [], 'url.port': [] }, + '' + ) + ).toBeUndefined(); }); it('creates a filter string for filters only', async () => { - const res = await generateFilterDSL(mockGetIndexPattern, { - 'monitor.type': [], - 'observer.geo.name': ['us-east', 'us-west'], - tags: [], - 'url.port': [], - }); + const res = await generateFilterDSL( + mockGetIndexPattern, + { + 'monitor.type': [], + 'observer.geo.name': ['us-east', 'us-west'], + tags: [], + 'url.port': [], + }, + '' + ); expect(res).toMatchInlineSnapshot(` Object { "bool": Object { @@ -866,8 +994,13 @@ describe('status check alert', () => { }); it('creates a filter string for search only', async () => { - expect(await generateFilterDSL(mockGetIndexPattern, undefined, 'monitor.id: "kibana-dev"')) - .toMatchInlineSnapshot(` + expect( + await generateFilterDSL( + mockGetIndexPattern, + { 'monitor.type': [], 'observer.geo.name': [], tags: [], 'url.port': [] }, + 'monitor.id: "kibana-dev"' + ) + ).toMatchInlineSnapshot(` Object { "bool": Object { "minimum_should_match": 1, @@ -987,217 +1120,159 @@ describe('status check alert', () => { }); describe('uniqueMonitorIds', () => { - let items: GetMonitorStatusResult[]; + let downItems: GetMonitorStatusResult[]; + let availItems: GetMonitorAvailabilityResult[]; beforeEach(() => { - items = [ + downItems = [ { - monitor_id: 'first', + monitorId: 'first', location: 'harrisburg', count: 234, status: 'down', + monitorInfo: makePing({}), }, { - monitor_id: 'first', + monitorId: 'first', location: 'fairbanks', count: 312, status: 'down', + monitorInfo: makePing({}), }, { - monitor_id: 'second', + monitorId: 'second', location: 'harrisburg', count: 325, status: 'down', + monitorInfo: makePing({}), }, { - monitor_id: 'second', + monitorId: 'second', location: 'fairbanks', count: 331, status: 'down', + monitorInfo: makePing({}), }, + ]; + + availItems = [ { - monitor_id: 'third', - location: 'harrisburg', - count: 331, - status: 'down', - }, - { - monitor_id: 'third', - location: 'fairbanks', - count: 342, - status: 'down', - }, - { - monitor_id: 'fourth', + monitorId: 'first', location: 'harrisburg', - count: 355, - status: 'down', + monitorInfo: makePing({}), + up: 2134, + down: 213, + availabilityRatio: 0.909245845760545, }, { - monitor_id: 'fourth', + monitorId: 'first', location: 'fairbanks', - count: 342, - status: 'down', + monitorInfo: makePing({}), + up: 2134, + down: 213, + availabilityRatio: 0.909245845760545, }, { - monitor_id: 'fifth', + monitorId: 'second', location: 'harrisburg', - count: 342, - status: 'down', + monitorInfo: makePing({}), + up: 2134, + down: 213, + availabilityRatio: 0.909245845760545, }, { - monitor_id: 'fifth', + monitorId: 'second', location: 'fairbanks', - count: 342, - status: 'down', + monitorInfo: makePing({}), + up: 2134, + down: 213, + availabilityRatio: 0.909245845760545, }, ]; }); it('creates a set of unique IDs from a list of composite unique objects', () => { - expect(uniqueMonitorIds(items)).toEqual( - new Set(['first', 'second', 'third', 'fourth', 'fifth']) - ); - }); - }); - - describe('contextMessage', () => { - let ids: string[]; - beforeEach(() => { - ids = ['first', 'second', 'third', 'fourth', 'fifth']; - }); - - it('creates a message with appropriate number of monitors', () => { - expect(contextMessage(ids, 3, [], '0', false, true)).toMatchInlineSnapshot( - `"Down monitors: first, second, third... and 2 other monitors"` - ); - }); - - it('throws an error if `max` is less than 2', () => { - expect(() => contextMessage(ids, 1, [], '0', false, true)).toThrowErrorMatchingInlineSnapshot( - '"Maximum value must be greater than 2, received 1."' - ); - }); - - it('returns only the ids if length < max', () => { - expect(contextMessage(ids.slice(0, 2), 3, [], '0', false, true)).toMatchInlineSnapshot( - `"Down monitors: first, second"` - ); - }); - - it('returns a default message when no monitors are provided', () => { - expect(contextMessage([], 3, [], '0', false, true)).toMatchInlineSnapshot( - `"No down monitor IDs received"` + expect(getUniqueIdsByLoc(downItems, availItems)).toEqual( + new Set([ + 'firstharrisburg', + 'firstfairbanks', + 'secondharrisburg', + 'secondfairbanks', + ]) ); }); }); - describe('availabilityMessage', () => { - it('creates message for singular item', () => { + describe('statusMessage', () => { + it('creates message for down item', () => { expect( - availabilityMessage( - [ - { - monitorId: 'test-node-service', - location: 'fairbanks', - name: 'Test Node Service', - url: 'http://localhost:12349', - up: 821.0, - down: 2450.0, - availabilityRatio: 0.25099357994497096, - }, - ], - '59' + getStatusMessage( + makePing({ + id: 'test-node-service', + location: 'fairbanks', + name: 'Test Node Service', + url: 'http://localhost:12349', + }) ) - ).toMatchInlineSnapshot(` - "Monitor Below Availability Threshold (59 %): - Test Node Service(http://localhost:12349): 25.099% - " - `); + ).toMatchInlineSnapshot(`"down"`); }); - it('creates message for multiple items', () => { + it('creates message for availability item', () => { expect( - availabilityMessage( - [ - { - monitorId: 'test-node-service', - location: 'fairbanks', + getStatusMessage( + undefined, + { + monitorId: 'test-node-service', + location: 'harrisburg', + up: 3389.0, + down: 2450.0, + availabilityRatio: 0.5804076040417879, + monitorInfo: makePing({ name: 'Test Node Service', url: 'http://localhost:12349', - up: 821.0, - down: 2450.0, - availabilityRatio: 0.25099357994497096, - }, - { - monitorId: 'test-node-service', + id: 'test-node-service', location: 'harrisburg', - name: 'Test Node Service', - url: 'http://localhost:12349', - up: 3389.0, - down: 2450.0, - availabilityRatio: 0.5804076040417879, - }, - ], - '59' + }), + }, + { + threshold: '90', + range: 5, + rangeUnit: 'm', + } ) - ).toMatchInlineSnapshot(` - "Top 2 Monitors Below Availability Threshold (59 %): - Test Node Service(http://localhost:12349): 25.099% - Test Node Service(http://localhost:12349): 58.041% - " - `); + ).toMatchInlineSnapshot(`"below threshold with 58.04% availability expected is 90%"`); }); - it('caps message for multiple items', () => { + it('creates message for down and availability item', () => { expect( - availabilityMessage( - [ - { - monitorId: 'test-node-service', - location: 'fairbanks', + getStatusMessage( + makePing({ + id: 'test-node-service', + location: 'fairbanks', + name: 'Test Node Service', + url: 'http://localhost:12349', + }), + { + monitorId: 'test-node-service', + location: 'harrisburg', + up: 3389.0, + down: 2450.0, + availabilityRatio: 0.5804076040417879, + monitorInfo: makePing({ name: 'Test Node Service', url: 'http://localhost:12349', - up: 821.0, - down: 2450.0, - availabilityRatio: 0.250993579944971, - }, - { - monitorId: 'test-node-service', + id: 'test-node-service', location: 'harrisburg', - name: 'Test Node Service', - url: 'http://localhost:12349', - up: 3389.0, - down: 2450.0, - availabilityRatio: 0.58040760404178, - }, - { - monitorId: 'test-node-service', - location: 'berlin', - name: 'Test Node Service', - url: 'http://localhost:12349', - up: 3645.0, - down: 2982.0, - availabilityRatio: 0.550022634676324, - }, - { - monitorId: 'test-node-service', - location: 'st paul', - name: 'Test Node Service', - url: 'http://localhost:12349', - up: 3601.0, - down: 2681.0, - availabilityRatio: 0.573225087551735, - }, - ], - '59' + }), + }, + { + threshold: '90', + range: 5, + rangeUnit: 'm', + } ) - ).toMatchInlineSnapshot(` - "Top 3 Monitors Below Availability Threshold (59 %): - Test Node Service(http://localhost:12349): 25.099% - Test Node Service(http://localhost:12349): 55.002% - Test Node Service(http://localhost:12349): 57.323% - " - `); + ).toMatchInlineSnapshot( + `"down and also below threshold with 58.04% availability expected is 90%"` + ); }); }); }); diff --git a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts index a71913d0eea9a..9ed453d286285 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/duration_anomaly.ts @@ -12,12 +12,12 @@ import { ACTION_GROUP_DEFINITIONS } from '../../../common/constants/alerts'; import { commonStateTranslations, durationAnomalyTranslations } from './translations'; import { AnomaliesTableRecord } from '../../../../ml/common/types/anomalies'; import { getSeverityType } from '../../../../ml/common/util/anomaly_utils'; -import { getLatestMonitor } from '../requests'; import { savedObjectsAdapter } from '../saved_objects'; import { UptimeCorePlugins } from '../adapters/framework'; import { UptimeAlertTypeFactory } from './types'; import { Ping } from '../../../common/runtime_types/ping'; import { getMLJobId } from '../../../common/lib'; +import { getLatestMonitor } from '../requests/get_latest_monitor'; const { DURATION_ANOMALY } = ACTION_GROUP_DEFINITIONS; diff --git a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts index 8ca2e857a52c9..134472ba0693f 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/status_check.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/status_check.ts @@ -5,225 +5,46 @@ */ import { schema } from '@kbn/config-schema'; -import { isRight } from 'fp-ts/lib/Either'; -import { ThrowReporter } from 'io-ts/lib/ThrowReporter'; import { i18n } from '@kbn/i18n'; -import { AlertExecutorOptions } from '../../../../alerts/server'; +import { ILegacyScopedClusterClient } from 'kibana/server'; +import Mustache from 'mustache'; import { UptimeAlertTypeFactory } from './types'; -import { GetMonitorStatusResult } from '../requests'; import { esKuery, IIndexPattern } from '../../../../../../src/plugins/data/server'; import { JsonObject } from '../../../../../../src/plugins/kibana_utils/common'; import { - StatusCheckParamsType, - StatusCheckParams, StatusCheckFilters, - AtomicStatusCheckParamsType, - MonitorAvailabilityType, DynamicSettings, + Ping, + GetMonitorAvailabilityParams, } from '../../../common/runtime_types'; import { ACTION_GROUP_DEFINITIONS } from '../../../common/constants/alerts'; -import { savedObjectsAdapter } from '../saved_objects'; import { updateState } from './common'; -import { commonStateTranslations } from './translations'; +import { commonMonitorStateI18, commonStateTranslations, DOWN_LABEL } from './translations'; import { stringifyKueries, combineFiltersAndUserSearch } from '../../../common/lib'; import { GetMonitorAvailabilityResult } from '../requests/get_monitor_availability'; import { UMServerLibs } from '../lib'; +import { GetMonitorStatusResult } from '../requests/get_monitor_status'; +import { UNNAMED_LOCATION } from '../../../common/constants'; +import { uptimeAlertWrapper } from './uptime_alert_wrapper'; +import { MonitorStatusTranslations } from '../../../common/translations'; const { MONITOR_STATUS } = ACTION_GROUP_DEFINITIONS; -/** - * Reduce a composite-key array of status results to a set of unique IDs. - * @param items to reduce - */ -export const uniqueMonitorIds = (items: GetMonitorStatusResult[]): Set => - // eslint-disable-next-line @typescript-eslint/naming-convention - items.reduce((acc, { monitor_id }) => { - acc.add(monitor_id); - return acc; - }, new Set()); - -const sortAvailabilityResultByRatioAsc = ( - a: GetMonitorAvailabilityResult, - b: GetMonitorAvailabilityResult -): number => (a.availabilityRatio ?? 100) - (b.availabilityRatio ?? 100); - -/** - * Map an availability result object to a descriptive string. - */ -const mapAvailabilityResultToString = ({ - availabilityRatio, - name, - monitorId, - url, -}: GetMonitorAvailabilityResult) => - i18n.translate('xpack.uptime.alerts.availability.monitorSummary', { - defaultMessage: '{nameOrId}({url}): {availabilityRatio}%', - values: { - nameOrId: name || monitorId, - url, - availabilityRatio: ((availabilityRatio ?? 1.0) * 100).toPrecision(5), - }, - }); - -const reduceAvailabilityStringsToMessage = (threshold: string) => ( - prev: string, - cur: string, - _ind: number, - array: string[] -) => { - let prefix: string = ''; - if (prev !== '') { - prefix = prev; - } else if (array.length > 1) { - prefix = i18n.translate('xpack.uptime.alerts.availability.multiItemTitle', { - defaultMessage: `Top {monitorCount} Monitors Below Availability Threshold ({threshold} %):\n`, - values: { - monitorCount: Math.min(array.length, MESSAGE_AVAILABILITY_MAX), - threshold, - }, - }); - } else { - prefix = i18n.translate('xpack.uptime.alerts.availability.singleItemTitle', { - defaultMessage: `Monitor Below Availability Threshold ({threshold} %):\n`, - values: { threshold }, - }); - } - return prefix + `${cur}\n`; -}; - -const MESSAGE_AVAILABILITY_MAX = 3; - -/** - * Creates a summary message from a list of availability check result objects. - * @param availabilityResult the list of results - * @param threshold the threshold used by the check - */ -export const availabilityMessage = ( - availabilityResult: GetMonitorAvailabilityResult[], - threshold: string, - max: number = MESSAGE_AVAILABILITY_MAX -): string => { - return availabilityResult.length > 0 - ? // if there are results, map each item to a descriptive string, and reduce the list - availabilityResult - .sort(sortAvailabilityResultByRatioAsc) - .slice(0, max) - .map(mapAvailabilityResultToString) - .reduce(reduceAvailabilityStringsToMessage(threshold), '') - : // if there are no results, return an empty list default string - i18n.translate('xpack.uptime.alerts.availability.emptyMessage', { - defaultMessage: `No monitors were below Availability Threshold ({threshold} %)`, - values: { - threshold, - }, - }); -}; - -/** - * Generates a message to include in contexts of alerts. - * @param monitors the list of monitors to include in the message - * @param max the maximum number of items the summary should contain - */ -export const contextMessage = ( - monitorIds: string[], - max: number, - availabilityResult: GetMonitorAvailabilityResult[], - availabilityThreshold: string, - availabilityWasChecked: boolean, - statusWasChecked: boolean -): string => { - const MIN = 2; - if (max < MIN) throw new Error(`Maximum value must be greater than ${MIN}, received ${max}.`); - - // generate the message - let message = ''; - if (statusWasChecked) { - if (monitorIds.length === 1) { - message = i18n.translate('xpack.uptime.alerts.message.singularTitle', { - defaultMessage: 'Down monitor: ', - }); - } else if (monitorIds.length) { - message = i18n.translate('xpack.uptime.alerts.message.multipleTitle', { - defaultMessage: 'Down monitors: ', - }); - } else { - message = i18n.translate('xpack.uptime.alerts.message.emptyTitle', { - defaultMessage: 'No down monitor IDs received', - }); - } - - for (let i = 0; i < monitorIds.length; i++) { - const id = monitorIds[i]; - if (i === max) { - message = - message + - i18n.translate('xpack.uptime.alerts.message.overflowBody', { - defaultMessage: `... and {overflowCount} other monitors`, - values: { - overflowCount: monitorIds.length - i, - }, - }); - break; - } else if (i === 0) { - message = message + id; - } else { - message = message + `, ${id}`; - } - } - } - - if (availabilityWasChecked) { - const availabilityMsg = availabilityMessage(availabilityResult, availabilityThreshold); - return message ? message + '\n' + availabilityMsg : availabilityMsg; - } +const uniqueDownMonitorIds = (items: GetMonitorStatusResult[]): Set => + items.reduce((acc, { monitorId, location }) => acc.add(monitorId + location), new Set()); - return message; -}; +const uniqueAvailMonitorIds = (items: GetMonitorAvailabilityResult[]): Set => + items.reduce((acc, { monitorId, location }) => acc.add(monitorId + location), new Set()); -/** - * Creates an exhaustive list of all the down monitors. - * @param list all the monitors that are down - * @param sizeLimit the max monitors, we shouldn't allow an arbitrarily long string - */ -export const fullListByIdAndLocation = ( - list: GetMonitorStatusResult[], - sizeLimit: number = 1000 +export const getUniqueIdsByLoc = ( + downMonitorsByLocation: GetMonitorStatusResult[], + availabilityResults: GetMonitorAvailabilityResult[] ) => { - return ( - list - // sort by id, then location - .sort((a, b) => { - if (a.monitor_id > b.monitor_id) { - return 1; - } else if (a.monitor_id < b.monitor_id) { - return -1; - } else if (a.location > b.location) { - return 1; - } - return -1; - }) - .slice(0, sizeLimit) - .reduce( - (cur, { monitor_id: id, location }) => - cur + `${id} from ${location ?? 'Unnamed location'}; `, - '' - ) + - (sizeLimit < list.length - ? i18n.translate('xpack.uptime.alerts.message.fullListOverflow', { - defaultMessage: '...and {overflowCount} other {pluralizedMonitor}', - values: { - pluralizedMonitor: - list.length - sizeLimit === 1 ? 'monitor/location' : 'monitors/locations', - overflowCount: list.length - sizeLimit, - }, - }) - : '') - ); -}; + const uniqueDownsIdsByLoc = uniqueDownMonitorIds(downMonitorsByLocation); + const uniqueAvailIdsByLoc = uniqueAvailMonitorIds(availabilityResults); -// Right now the maximum number of monitors shown in the message is hardcoded here. -// we might want to make this a parameter in the future -const DEFAULT_MAX_MESSAGE_ROWS = 3; + return new Set([...uniqueDownsIdsByLoc, ...uniqueAvailIdsByLoc]); +}; export const hasFilters = (filters?: StatusCheckFilters) => { if (!filters) return false; @@ -237,25 +58,18 @@ export const hasFilters = (filters?: StatusCheckFilters) => { export const generateFilterDSL = async ( getIndexPattern: () => Promise, - filters?: StatusCheckFilters, - search?: string + filters: StatusCheckFilters, + search: string ): Promise => { const filtersExist = hasFilters(filters); if (!filtersExist && !search) return undefined; - let filterString: string | undefined; + let filterString = ''; if (filtersExist) { filterString = stringifyKueries(new Map(Object.entries(filters ?? {}))); } - let combinedString: string | undefined; - if (filterString && search) { - combinedString = combineFiltersAndUserSearch(filterString, search); - } else if (filterString) { - combinedString = filterString; - } else if (search) { - combinedString = search; - } + const combinedString = combineFiltersAndUserSearch(filterString, search); return esKuery.toElasticsearchQuery( esKuery.fromKueryExpression(combinedString ?? ''), @@ -266,183 +80,232 @@ export const generateFilterDSL = async ( const formatFilterString = async ( libs: UMServerLibs, dynamicSettings: DynamicSettings, - options: AlertExecutorOptions, - filters?: StatusCheckFilters, - search?: string + callES: ILegacyScopedClusterClient['callAsCurrentUser'], + filters: StatusCheckFilters, + search: string ) => - JSON.stringify( - await generateFilterDSL( - () => - libs.requests.getIndexPattern({ - callES: options.services.callCluster, - dynamicSettings, - }), - filters, - search - ) + await generateFilterDSL( + () => + libs.requests.getIndexPattern({ + callES, + dynamicSettings, + }), + filters, + search ); -export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) => ({ - id: 'xpack.uptime.alerts.monitorStatus', - name: i18n.translate('xpack.uptime.alerts.monitorStatus', { - defaultMessage: 'Uptime monitor status', - }), - validate: { - params: schema.object({ - availability: schema.maybe( - schema.object({ - range: schema.number(), - rangeUnit: schema.string(), - threshold: schema.string(), - }) - ), - filters: schema.maybe( - schema.oneOf([ - // deprecated - schema.object({ - 'monitor.type': schema.maybe(schema.arrayOf(schema.string())), - 'observer.geo.name': schema.maybe(schema.arrayOf(schema.string())), - tags: schema.maybe(schema.arrayOf(schema.string())), - 'url.port': schema.maybe(schema.arrayOf(schema.string())), - }), - schema.string(), - ]) - ), - // deprecated - locations: schema.maybe(schema.arrayOf(schema.string())), - numTimes: schema.number(), - search: schema.maybe(schema.string()), - shouldCheckStatus: schema.maybe(schema.boolean()), - shouldCheckAvailability: schema.maybe(schema.boolean()), - timerangeCount: schema.maybe(schema.number()), - timerangeUnit: schema.maybe(schema.string()), - // deprecated - timerange: schema.maybe( - schema.object({ - from: schema.string(), - to: schema.string(), - }) - ), - version: schema.maybe(schema.number()), - }), - }, - defaultActionGroupId: MONITOR_STATUS.id, - actionGroups: [ - { - id: MONITOR_STATUS.id, - name: MONITOR_STATUS.name, - }, - ], - actionVariables: { - context: [ +export const getMonitorSummary = (monitorInfo: Ping) => { + return { + monitorUrl: monitorInfo.url?.full, + monitorId: monitorInfo.monitor?.id, + monitorName: monitorInfo.monitor?.name ?? monitorInfo.monitor?.id, + monitorType: monitorInfo.monitor?.type, + latestErrorMessage: monitorInfo.error?.message, + observerLocation: monitorInfo.observer?.geo?.name ?? UNNAMED_LOCATION, + observerHostname: monitorInfo.agent?.name, + }; +}; + +const generateMessageForOlderVersions = (fields: Record) => { + const messageTemplate = MonitorStatusTranslations.defaultActionMessage; + + // Monitor {{state.monitorName}} with url {{{state.monitorUrl}}} is {{state.statusMessage}} from + // {{state.observerLocation}}. The latest error message is {{{state.latestErrorMessage}}} + + return Mustache.render(messageTemplate, { state: { ...fields } }); +}; + +export const getStatusMessage = ( + downMonInfo?: Ping, + availMonInfo?: GetMonitorAvailabilityResult, + availability?: GetMonitorAvailabilityParams +) => { + let statusMessage = ''; + if (downMonInfo) { + statusMessage = DOWN_LABEL; + } + let availabilityMessage = ''; + + if (availMonInfo) { + availabilityMessage = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.availabilityMessage', { - name: 'message', - description: i18n.translate( - 'xpack.uptime.alerts.monitorStatus.actionVariables.context.message.description', - { - defaultMessage: 'A generated message summarizing the currently down monitors', - } - ), - }, + defaultMessage: + 'below threshold with {availabilityRatio}% availability expected is {expectedAvailability}%', + values: { + availabilityRatio: (availMonInfo.availabilityRatio! * 100).toFixed(2), + expectedAvailability: availability?.threshold, + }, + } + ); + } + if (availMonInfo && downMonInfo) { + return i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.downAndAvailabilityMessage', { - name: 'downMonitorsWithGeo', - description: i18n.translate( - 'xpack.uptime.alerts.monitorStatus.actionVariables.context.downMonitorsWithGeo.description', - { - defaultMessage: - 'A generated summary that shows some or all of the monitors detected as "down" by the alert', - } + defaultMessage: '{statusMessage} and also {availabilityMessage}', + values: { + statusMessage, + availabilityMessage, + }, + } + ); + } + return statusMessage + availabilityMessage; +}; + +export const statusCheckAlertFactory: UptimeAlertTypeFactory = (_server, libs) => + uptimeAlertWrapper({ + id: 'xpack.uptime.alerts.monitorStatus', + name: i18n.translate('xpack.uptime.alerts.monitorStatus', { + defaultMessage: 'Uptime monitor status', + }), + validate: { + params: schema.object({ + availability: schema.maybe( + schema.object({ + range: schema.number(), + rangeUnit: schema.string(), + threshold: schema.string(), + }) + ), + filters: schema.maybe( + schema.oneOf([ + // deprecated + schema.object({ + 'monitor.type': schema.maybe(schema.arrayOf(schema.string())), + 'observer.geo.name': schema.maybe(schema.arrayOf(schema.string())), + tags: schema.maybe(schema.arrayOf(schema.string())), + 'url.port': schema.maybe(schema.arrayOf(schema.string())), + }), + schema.string(), + ]) + ), + // deprecated + locations: schema.maybe(schema.arrayOf(schema.string())), + numTimes: schema.number(), + search: schema.maybe(schema.string()), + shouldCheckStatus: schema.boolean(), + shouldCheckAvailability: schema.boolean(), + timerangeCount: schema.maybe(schema.number()), + timerangeUnit: schema.maybe(schema.string()), + // deprecated + timerange: schema.maybe( + schema.object({ + from: schema.string(), + to: schema.string(), + }) ), + version: schema.maybe(schema.number()), + }), + }, + defaultActionGroupId: MONITOR_STATUS.id, + actionGroups: [ + { + id: MONITOR_STATUS.id, + name: MONITOR_STATUS.name, }, ], - state: [...commonStateTranslations], - }, - producer: 'uptime', - async executor(options: AlertExecutorOptions) { - const { params: rawParams } = options; - const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( - options.services.savedObjectsClient - ); - const atomicDecoded = AtomicStatusCheckParamsType.decode(rawParams); - const availabilityDecoded = MonitorAvailabilityType.decode(rawParams); - const decoded = StatusCheckParamsType.decode(rawParams); - let filterString: string = ''; - let params: StatusCheckParams; - if (isRight(atomicDecoded)) { - const { filters, search, numTimes, timerangeCount, timerangeUnit } = atomicDecoded.right; - const timerange = { from: `now-${String(timerangeCount) + timerangeUnit}`, to: 'now' }; - filterString = await formatFilterString(libs, dynamicSettings, options, filters, search); - params = { - timerange, + actionVariables: { + context: [ + { + name: 'message', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.context.message.description', + { + defaultMessage: 'A generated message summarizing the currently down monitors', + } + ), + }, + { + name: 'downMonitorsWithGeo', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.context.downMonitorsWithGeo.description', + { + defaultMessage: + 'A generated summary that shows some or all of the monitors detected as "down" by the alert', + } + ), + }, + ], + state: [...commonMonitorStateI18, ...commonStateTranslations], + }, + async executor( + { params: rawParams, state, services: { alertInstanceFactory } }, + callES, + dynamicSettings + ) { + const { + filters, + search, numTimes, - locations: [], - filters: filterString, - }; - } else if (isRight(decoded)) { - params = decoded.right; - } else if (!isRight(availabilityDecoded)) { - ThrowReporter.report(decoded); - return { - error: 'Alert param types do not conform to required shape.', + timerangeCount, + timerangeUnit, + availability, + shouldCheckAvailability, + shouldCheckStatus, + timerange: oldVersionTimeRange, + } = rawParams; + + const timerange = oldVersionTimeRange || { + from: `now-${String(timerangeCount) + timerangeUnit}`, + to: 'now', }; - } - let availabilityResults: GetMonitorAvailabilityResult[] = []; - if ( - isRight(availabilityDecoded) && - availabilityDecoded.right.shouldCheckAvailability === true - ) { - const { filters, search } = availabilityDecoded.right; - if (filterString === '' && (filters || search)) { - filterString = await formatFilterString(libs, dynamicSettings, options, filters, search); + const filterString = await formatFilterString(libs, dynamicSettings, callES, filters, search); + + let availabilityResults: GetMonitorAvailabilityResult[] = []; + if (shouldCheckAvailability) { + availabilityResults = await libs.requests.getMonitorAvailability({ + callES, + dynamicSettings, + ...availability, + filters: JSON.stringify(filterString) || undefined, + }); } - availabilityResults = await libs.requests.getMonitorAvailability({ - callES: options.services.callCluster, - dynamicSettings, - ...availabilityDecoded.right.availability, - filters: filterString || undefined, - }); - } + let downMonitorsByLocation: GetMonitorStatusResult[] = []; - /* This is called `monitorsByLocation` but it's really - * monitors by location by status. The query we run to generate this - * filters on the status field, so effectively there should be one and only one - * status represented in the result set. */ - let monitorsByLocation: GetMonitorStatusResult[] = []; - - // old alert versions are missing this field so it must default to true - const verifiedParams = StatusCheckParamsType.decode(params!); - if (isRight(verifiedParams) && (verifiedParams.right?.shouldCheckStatus ?? true)) { - monitorsByLocation = await libs.requests.getMonitorStatus({ - callES: options.services.callCluster, - dynamicSettings, - ...verifiedParams.right, - }); - } + // if oldVersionTimeRange present means it's 7.7 format and + // after that shouldCheckStatus should be explicitly false + if (!(!oldVersionTimeRange && shouldCheckStatus === false)) { + downMonitorsByLocation = await libs.requests.getMonitorStatus({ + callES, + dynamicSettings, + timerange, + numTimes, + locations: [], + filters: filterString, + }); + } - // if no monitors are down for our query, we don't need to trigger an alert - if (monitorsByLocation.length || availabilityResults.length) { - const uniqueIds = uniqueMonitorIds(monitorsByLocation); - const alertInstance = options.services.alertInstanceFactory(MONITOR_STATUS.id); - alertInstance.replaceState({ - ...options.state, - monitors: monitorsByLocation, - ...updateState(options.state, true), - }); - alertInstance.scheduleActions(MONITOR_STATUS.id, { - message: contextMessage( - Array.from(uniqueIds.keys()), - DEFAULT_MAX_MESSAGE_ROWS, - availabilityResults, - isRight(availabilityDecoded) ? availabilityDecoded.right.availability.threshold : '100', - isRight(availabilityDecoded) && availabilityDecoded.right.shouldCheckAvailability, - rawParams?.shouldCheckStatus ?? false - ), - downMonitorsWithGeo: fullListByIdAndLocation(monitorsByLocation), + const mergedIdsByLoc = getUniqueIdsByLoc(downMonitorsByLocation, availabilityResults); + + mergedIdsByLoc.forEach((monIdByLoc) => { + const alertInstance = alertInstanceFactory(MONITOR_STATUS.id + monIdByLoc); + + const availMonInfo = availabilityResults.find( + ({ monitorId, location }) => monitorId + location === monIdByLoc + ); + + const downMonInfo = downMonitorsByLocation.find( + ({ monitorId, location }) => monitorId + location === monIdByLoc + )?.monitorInfo; + + const monitorSummary = getMonitorSummary(downMonInfo || availMonInfo?.monitorInfo!); + const statusMessage = getStatusMessage(downMonInfo!, availMonInfo!, availability); + + alertInstance.replaceState({ + ...updateState(state, true), + ...monitorSummary, + statusMessage, + }); + + alertInstance.scheduleActions(MONITOR_STATUS.id, { + message: generateMessageForOlderVersions({ ...monitorSummary, statusMessage }), + }); }); - } - return updateState(options.state, monitorsByLocation.length > 0); - }, -}); + return updateState(state, downMonitorsByLocation.length > 0); + }, + }); diff --git a/x-pack/plugins/uptime/server/lib/alerts/translations.ts b/x-pack/plugins/uptime/server/lib/alerts/translations.ts index 50eedcd4fa69e..8e5c0e76ad589 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/translations.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/translations.ts @@ -6,6 +6,79 @@ import { i18n } from '@kbn/i18n'; +export const commonMonitorStateI18 = [ + { + name: 'monitorName', + description: i18n.translate('xpack.uptime.alerts.monitorStatus.actionVariables.state.monitor', { + defaultMessage: 'A human friendly rendering of name or ID, preferring name (e.g. My Monitor)', + }), + }, + { + name: 'monitorId', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.state.monitorId', + { + defaultMessage: 'ID of the monitor.', + } + ), + }, + { + name: 'monitorUrl', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.state.monitorUrl', + { + defaultMessage: 'URL of the monitor.', + } + ), + }, + { + name: 'monitorType', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.state.monitorType', + { + defaultMessage: 'Type (e.g. HTTP/TCP) of the monitor.', + } + ), + }, + { + name: 'statusMessage', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.state.statusMessage', + { + defaultMessage: + 'Status message e.g down or is below availability threshold in case of availability check or both.', + } + ), + }, + { + name: 'latestErrorMessage', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.state.lastErrorMessage', + { + defaultMessage: 'Monitor latest error message', + } + ), + }, + { + name: 'observerLocation', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.state.observerLocation', + { + defaultMessage: 'Observer location from which heartbeat check is performed.', + } + ), + }, + { + name: 'observerHostname', + description: i18n.translate( + 'xpack.uptime.alerts.monitorStatus.actionVariables.state.observerHostname', + { + defaultMessage: 'Observer hostname from which heartbeat check is performed.', + } + ), + }, +]; + export const commonStateTranslations = [ { name: 'firstCheckedAt', @@ -238,3 +311,7 @@ export const durationAnomalyTranslations = { }, ], }; + +export const DOWN_LABEL = i18n.translate('xpack.uptime.alerts.monitorStatus.actionVariables.down', { + defaultMessage: 'down', +}); diff --git a/x-pack/plugins/uptime/server/lib/alerts/types.ts b/x-pack/plugins/uptime/server/lib/alerts/types.ts index 172930bc3dd3b..0a80b36046860 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/types.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/types.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AlertType } from '../../../../alerts/server'; import { UptimeCorePlugins, UptimeCoreSetup } from '../adapters'; import { UMServerLibs } from '../lib'; +import { AlertType } from '../../../../alerts/server'; export type UptimeAlertTypeFactory = ( server: UptimeCoreSetup, diff --git a/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts b/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts new file mode 100644 index 0000000000000..5963b371f844f --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/alerts/uptime_alert_wrapper.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ILegacyScopedClusterClient } from 'kibana/server'; +import { AlertExecutorOptions, AlertType, State } from '../../../../alerts/server'; +import { savedObjectsAdapter } from '../saved_objects'; +import { DynamicSettings } from '../../../common/runtime_types'; + +export interface UptimeAlertType extends Omit { + executor: ( + options: AlertExecutorOptions, + callES: ILegacyScopedClusterClient['callAsCurrentUser'], + dynamicSettings: DynamicSettings + ) => Promise; +} + +export const uptimeAlertWrapper = (uptimeAlert: UptimeAlertType) => ({ + ...uptimeAlert, + producer: 'uptime', + executor: async (options: AlertExecutorOptions) => { + const { + services: { callCluster: callES }, + } = options; + + const dynamicSettings = await savedObjectsAdapter.getUptimeDynamicSettings( + options.services.savedObjectsClient + ); + + return uptimeAlert.executor(options, callES, dynamicSettings); + }, +}); diff --git a/x-pack/plugins/uptime/server/lib/compose/kibana.ts b/x-pack/plugins/uptime/server/lib/compose/kibana.ts index edda5cb283323..783a77b9e5377 100644 --- a/x-pack/plugins/uptime/server/lib/compose/kibana.ts +++ b/x-pack/plugins/uptime/server/lib/compose/kibana.ts @@ -5,23 +5,17 @@ */ import { UMKibanaBackendFrameworkAdapter } from '../adapters/framework'; -import * as requests from '../requests'; +import { requests } from '../requests'; import { licenseCheck } from '../domains'; -import { UMDomainLibs, UMServerLibs } from '../lib'; +import { UMServerLibs } from '../lib'; import { UptimeCoreSetup } from '../adapters/framework'; export function compose(server: UptimeCoreSetup): UMServerLibs { const framework = new UMKibanaBackendFrameworkAdapter(server); - const domainLibs: UMDomainLibs = { - requests: { - ...requests, - }, - license: licenseCheck, - }; - return { framework, - ...domainLibs, + requests, + license: licenseCheck, }; } diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts index e014aa985a82d..f0222de02697d 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_availability.test.ts @@ -12,16 +12,9 @@ import { } from '../get_monitor_availability'; import { setupMockEsCompositeQuery } from './helper'; import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; -import { GetMonitorAvailabilityParams } from '../../../../common/runtime_types'; +import { GetMonitorAvailabilityParams, makePing, Ping } from '../../../../common/runtime_types'; interface AvailabilityTopHit { - _source: { - monitor: { - name: string; - }; - url: { - full: string; - }; - }; + _source: Ping; } interface AvailabilityDoc { @@ -46,11 +39,10 @@ interface AvailabilityDoc { const genBucketItem = ({ monitorId, location, - name, - url, up, down, availabilityRatio, + monitorInfo, }: GetMonitorAvailabilityResult): AvailabilityDoc => ({ key: { monitorId, @@ -61,14 +53,7 @@ const genBucketItem = ({ hits: { hits: [ { - _source: { - monitor: { - name, - }, - url: { - full: url, - }, - }, + _source: monitorInfo, }, ], }, @@ -148,10 +133,6 @@ describe('monitor availability', () => { }, "fields": Object { "top_hits": Object { - "_source": Array [ - "monitor.name", - "url.full", - ], "size": 1, "sort": Array [ Object { @@ -271,29 +252,26 @@ describe('monitor availability', () => { { monitorId: 'foo', location: 'harrisburg', - name: 'Foo', - url: 'http://foo.com', up: 456, down: 234, availabilityRatio: 0.660869565217391, + monitorInfo: makePing({}), }, { monitorId: 'foo', location: 'faribanks', - name: 'Foo', - url: 'http://foo.com', up: 450, down: 240, availabilityRatio: 0.652173913043478, + monitorInfo: makePing({}), }, { monitorId: 'bar', location: 'fairbanks', - name: 'Bar', - url: 'http://bar.com', up: 468, down: 212, availabilityRatio: 0.688235294117647, + monitorInfo: makePing({}), }, ], }, @@ -327,10 +305,6 @@ describe('monitor availability', () => { }, "fields": Object { "top_hits": Object { - "_source": Array [ - "monitor.name", - "url.full", - ], "size": 1, "sort": Array [ Object { @@ -417,27 +391,63 @@ describe('monitor availability', () => { "down": 234, "location": "harrisburg", "monitorId": "foo", - "name": "Foo", + "monitorInfo": Object { + "docId": "myDocId", + "monitor": Object { + "duration": Object { + "us": 100000, + }, + "id": "myId", + "ip": "127.0.0.1", + "name": undefined, + "status": "up", + "type": "myType", + }, + "timestamp": "2020-07-07T01:14:08Z", + }, "up": 456, - "url": "http://foo.com", }, Object { "availabilityRatio": 0.652173913043478, "down": 240, "location": "faribanks", "monitorId": "foo", - "name": "Foo", + "monitorInfo": Object { + "docId": "myDocId", + "monitor": Object { + "duration": Object { + "us": 100000, + }, + "id": "myId", + "ip": "127.0.0.1", + "name": undefined, + "status": "up", + "type": "myType", + }, + "timestamp": "2020-07-07T01:14:08Z", + }, "up": 450, - "url": "http://foo.com", }, Object { "availabilityRatio": 0.688235294117647, "down": 212, "location": "fairbanks", "monitorId": "bar", - "name": "Bar", + "monitorInfo": Object { + "docId": "myDocId", + "monitor": Object { + "duration": Object { + "us": 100000, + }, + "id": "myId", + "ip": "127.0.0.1", + "name": undefined, + "status": "up", + "type": "myType", + }, + "timestamp": "2020-07-07T01:14:08Z", + }, "up": 468, - "url": "http://bar.com", }, ] `); @@ -459,20 +469,18 @@ describe('monitor availability', () => { { monitorId: 'foo', location: 'harrisburg', - name: 'Foo', - url: 'http://foo.com', up: 243, down: 11, availabilityRatio: 0.956692913385827, + monitorInfo: makePing({}), }, { monitorId: 'foo', location: 'fairbanks', - name: 'Foo', - url: 'http://foo.com', up: 251, down: 13, availabilityRatio: 0.950757575757576, + monitorInfo: makePing({}), }, ], }, @@ -481,20 +489,18 @@ describe('monitor availability', () => { { monitorId: 'baz', location: 'harrisburg', - name: 'Baz', - url: 'http://baz.com', up: 341, down: 3, availabilityRatio: 0.991279069767442, + monitorInfo: makePing({}), }, { monitorId: 'baz', location: 'fairbanks', - name: 'Baz', - url: 'http://baz.com', up: 365, down: 5, availabilityRatio: 0.986486486486486, + monitorInfo: makePing({}), }, ], }, @@ -515,36 +521,84 @@ describe('monitor availability', () => { "down": 11, "location": "harrisburg", "monitorId": "foo", - "name": "Foo", + "monitorInfo": Object { + "docId": "myDocId", + "monitor": Object { + "duration": Object { + "us": 100000, + }, + "id": "myId", + "ip": "127.0.0.1", + "name": undefined, + "status": "up", + "type": "myType", + }, + "timestamp": "2020-07-07T01:14:08Z", + }, "up": 243, - "url": "http://foo.com", }, Object { "availabilityRatio": 0.950757575757576, "down": 13, "location": "fairbanks", "monitorId": "foo", - "name": "Foo", + "monitorInfo": Object { + "docId": "myDocId", + "monitor": Object { + "duration": Object { + "us": 100000, + }, + "id": "myId", + "ip": "127.0.0.1", + "name": undefined, + "status": "up", + "type": "myType", + }, + "timestamp": "2020-07-07T01:14:08Z", + }, "up": 251, - "url": "http://foo.com", }, Object { "availabilityRatio": 0.991279069767442, "down": 3, "location": "harrisburg", "monitorId": "baz", - "name": "Baz", + "monitorInfo": Object { + "docId": "myDocId", + "monitor": Object { + "duration": Object { + "us": 100000, + }, + "id": "myId", + "ip": "127.0.0.1", + "name": undefined, + "status": "up", + "type": "myType", + }, + "timestamp": "2020-07-07T01:14:08Z", + }, "up": 341, - "url": "http://baz.com", }, Object { "availabilityRatio": 0.986486486486486, "down": 5, "location": "fairbanks", "monitorId": "baz", - "name": "Baz", + "monitorInfo": Object { + "docId": "myDocId", + "monitor": Object { + "duration": Object { + "us": 100000, + }, + "id": "myId", + "ip": "127.0.0.1", + "name": undefined, + "status": "up", + "type": "myType", + }, + "timestamp": "2020-07-07T01:14:08Z", + }, "up": 365, - "url": "http://baz.com", }, ] `); @@ -565,10 +619,6 @@ describe('monitor availability', () => { }, "fields": Object { "top_hits": Object { - "_source": Array [ - "monitor.name", - "url.full", - ], "size": 1, "sort": Array [ Object { @@ -663,10 +713,6 @@ describe('monitor availability', () => { }, "fields": Object { "top_hits": Object { - "_source": Array [ - "monitor.name", - "url.full", - ], "size": 1, "sort": Array [ Object { @@ -833,18 +879,30 @@ describe('monitor availability', () => { "down": 2450, "location": "fairbanks", "monitorId": "test-node-service", - "name": "Test Node Service", + "monitorInfo": Object { + "monitor": Object { + "name": "Test Node Service", + }, + "url": Object { + "full": "http://localhost:12349", + }, + }, "up": 821, - "url": "http://localhost:12349", }, Object { "availabilityRatio": 0.5804076040417879, "down": 2450, "location": "harrisburg", "monitorId": "test-node-service", - "name": "Test Node Service", + "monitorInfo": Object { + "monitor": Object { + "name": "Test Node Service", + }, + "url": Object { + "full": "http://localhost:12349", + }, + }, "up": 3389, - "url": "http://localhost:12349", }, ] `); diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts index f12f1527fb56c..7dba71a8126e2 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_monitor_status.test.ts @@ -9,14 +9,14 @@ import { DYNAMIC_SETTINGS_DEFAULTS } from '../../../../common/constants'; import { setupMockEsCompositeQuery } from './helper'; export interface BucketItemCriteria { - monitor_id: string; + monitorId: string; status: string; location: string; doc_count: number; } interface BucketKey { - monitor_id: string; + monitorId: string; status: string; location: string; } @@ -27,19 +27,17 @@ interface BucketItem { } const genBucketItem = ({ - // eslint-disable-next-line @typescript-eslint/naming-convention - monitor_id, + monitorId, status, location, - // eslint-disable-next-line @typescript-eslint/naming-convention - doc_count, + doc_count: count, }: BucketItemCriteria): BucketItem => ({ key: { - monitor_id, + monitorId, status, location, }, - doc_count, + doc_count: count, }); describe('getMonitorStatus', () => { @@ -48,37 +46,37 @@ describe('getMonitorStatus', () => { [], genBucketItem ); - const exampleFilter = `{ - "bool": { - "should": [ + const exampleFilter = { + bool: { + should: [ { - "bool": { - "should": [ + bool: { + should: [ { - "match_phrase": { - "monitor.id": "apm-dev" - } - } + match_phrase: { + 'monitor.id': 'apm-dev', + }, + }, ], - "minimum_should_match": 1 - } + minimum_should_match: 1, + }, }, { - "bool": { - "should": [ + bool: { + should: [ { - "match_phrase": { - "monitor.id": "auto-http-0X8D6082B94BBE3B8A" - } - } + match_phrase: { + 'monitor.id': 'auto-http-0X8D6082B94BBE3B8A', + }, + }, ], - "minimum_should_match": 1 - } - } + minimum_should_match: 1, + }, + }, ], - "minimum_should_match": 1 - } - }`; + minimum_should_match: 1, + }, + }; await getMonitorStatus({ callES, dynamicSettings: DYNAMIC_SETTINGS_DEFAULTS, @@ -98,11 +96,18 @@ describe('getMonitorStatus', () => { "body": Object { "aggs": Object { "monitors": Object { + "aggs": Object { + "fields": Object { + "top_hits": Object { + "size": 1, + }, + }, + }, "composite": Object { "size": 2000, "sources": Array [ Object { - "monitor_id": Object { + "monitorId": Object { "terms": Object { "field": "monitor.id", }, @@ -203,11 +208,18 @@ describe('getMonitorStatus', () => { "body": Object { "aggs": Object { "monitors": Object { + "aggs": Object { + "fields": Object { + "top_hits": Object { + "size": 1, + }, + }, + }, "composite": Object { "size": 2000, "sources": Array [ Object { - "monitor_id": Object { + "monitorId": Object { "terms": Object { "field": "monitor.id", }, @@ -280,19 +292,19 @@ describe('getMonitorStatus', () => { { bucketCriteria: [ { - monitor_id: 'foo', + monitorId: 'foo', status: 'down', location: 'fairbanks', doc_count: 43, }, { - monitor_id: 'bar', + monitorId: 'bar', status: 'down', location: 'harrisburg', doc_count: 53, }, { - monitor_id: 'foo', + monitorId: 'foo', status: 'down', location: 'harrisburg', doc_count: 44, @@ -324,11 +336,18 @@ describe('getMonitorStatus', () => { "body": Object { "aggs": Object { "monitors": Object { + "aggs": Object { + "fields": Object { + "top_hits": Object { + "size": 1, + }, + }, + }, "composite": Object { "size": 2000, "sources": Array [ Object { - "monitor_id": Object { + "monitorId": Object { "terms": Object { "field": "monitor.id", }, @@ -377,25 +396,29 @@ describe('getMonitorStatus', () => { "index": "heartbeat-8*", } `); + expect(result.length).toBe(3); expect(result).toMatchInlineSnapshot(` Array [ Object { "count": 43, "location": "fairbanks", - "monitor_id": "foo", + "monitorId": "foo", + "monitorInfo": undefined, "status": "down", }, Object { "count": 53, "location": "harrisburg", - "monitor_id": "bar", + "monitorId": "bar", + "monitorInfo": undefined, "status": "down", }, Object { "count": 44, "location": "harrisburg", - "monitor_id": "foo", + "monitorId": "foo", + "monitorInfo": undefined, "status": "down", }, ] @@ -406,25 +429,25 @@ describe('getMonitorStatus', () => { const criteria = [ { after_key: { - monitor_id: 'foo', + monitorId: 'foo', location: 'harrisburg', status: 'down', }, bucketCriteria: [ { - monitor_id: 'foo', + monitorId: 'foo', status: 'down', location: 'fairbanks', doc_count: 43, }, { - monitor_id: 'bar', + monitorId: 'bar', status: 'down', location: 'harrisburg', doc_count: 53, }, { - monitor_id: 'foo', + monitorId: 'foo', status: 'down', location: 'harrisburg', doc_count: 44, @@ -433,25 +456,25 @@ describe('getMonitorStatus', () => { }, { after_key: { - monitor_id: 'bar', + monitorId: 'bar', status: 'down', location: 'fairbanks', }, bucketCriteria: [ { - monitor_id: 'sna', + monitorId: 'sna', status: 'down', location: 'fairbanks', doc_count: 21, }, { - monitor_id: 'fu', + monitorId: 'fu', status: 'down', location: 'fairbanks', doc_count: 21, }, { - monitor_id: 'bar', + monitorId: 'bar', status: 'down', location: 'fairbanks', doc_count: 45, @@ -461,13 +484,13 @@ describe('getMonitorStatus', () => { { bucketCriteria: [ { - monitor_id: 'sna', + monitorId: 'sna', status: 'down', location: 'harrisburg', doc_count: 21, }, { - monitor_id: 'fu', + monitorId: 'fu', status: 'down', location: 'harrisburg', doc_count: 21, @@ -489,54 +512,63 @@ describe('getMonitorStatus', () => { to: 'now-1m', }, }); + expect(result.length).toBe(8); expect(result).toMatchInlineSnapshot(` Array [ Object { "count": 43, "location": "fairbanks", - "monitor_id": "foo", + "monitorId": "foo", + "monitorInfo": undefined, "status": "down", }, Object { "count": 53, "location": "harrisburg", - "monitor_id": "bar", + "monitorId": "bar", + "monitorInfo": undefined, "status": "down", }, Object { "count": 44, "location": "harrisburg", - "monitor_id": "foo", + "monitorId": "foo", + "monitorInfo": undefined, "status": "down", }, Object { "count": 21, "location": "fairbanks", - "monitor_id": "sna", + "monitorId": "sna", + "monitorInfo": undefined, "status": "down", }, Object { "count": 21, "location": "fairbanks", - "monitor_id": "fu", + "monitorId": "fu", + "monitorInfo": undefined, "status": "down", }, Object { "count": 45, "location": "fairbanks", - "monitor_id": "bar", + "monitorId": "bar", + "monitorInfo": undefined, "status": "down", }, Object { "count": 21, "location": "harrisburg", - "monitor_id": "sna", + "monitorId": "sna", + "monitorInfo": undefined, "status": "down", }, Object { "count": 21, "location": "harrisburg", - "monitor_id": "fu", + "monitorId": "fu", + "monitorInfo": undefined, "status": "down", }, ] diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_availability.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_availability.ts index 798cefc404e1f..0801fc5769363 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_availability.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_availability.ts @@ -5,7 +5,7 @@ */ import { UMElasticsearchQueryFn } from '../adapters'; -import { GetMonitorAvailabilityParams } from '../../../common/runtime_types'; +import { GetMonitorAvailabilityParams, Ping } from '../../../common/runtime_types'; export interface AvailabilityKey { monitorId: string; @@ -14,20 +14,18 @@ export interface AvailabilityKey { export interface GetMonitorAvailabilityResult { monitorId: string; - location: string; - name: string; - url: string; up: number; down: number; + location: string; availabilityRatio: number | null; + monitorInfo: Ping; } export const formatBuckets = async (buckets: any[]): Promise => // eslint-disable-next-line @typescript-eslint/naming-convention buckets.map(({ key, fields, up_sum, down_sum, ratio }: any) => ({ ...key, - name: fields?.hits?.hits?.[0]?._source?.monitor.name, - url: fields?.hits?.hits?.[0]?._source?.url.full, + monitorInfo: fields?.hits?.hits?.[0]?._source, up: up_sum.value, down: down_sum.value, availabilityRatio: ratio.value, @@ -94,7 +92,6 @@ export const getMonitorAvailability: UMElasticsearchQueryFn< fields: { top_hits: { size: 1, - _source: ['monitor.name', 'url.full'], sort: [ { '@timestamp': { @@ -143,8 +140,8 @@ export const getMonitorAvailability: UMElasticsearchQueryFn< }; if (filters) { - const parsedFilters = JSON.parse(filters); - esParams.body.query.bool = { ...esParams.body.query.bool, ...parsedFilters.bool }; + const parsedFilter = JSON.parse(filters); + esParams.body.query.bool = { ...esParams.body.query.bool, ...parsedFilter.bool }; } if (afterKey) { diff --git a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts index a52bbfc8f2442..0788880994200 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_monitor_status.ts @@ -4,20 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ +import { JsonObject } from 'src/plugins/kibana_utils/public'; import { UMElasticsearchQueryFn } from '../adapters'; +import { Ping } from '../../../common/runtime_types/ping'; export interface GetMonitorStatusParams { - filters?: string; + filters?: JsonObject; locations: string[]; numTimes: number; timerange: { from: string; to: string }; } export interface GetMonitorStatusResult { - monitor_id: string; + monitorId: string; status: string; location: string; count: number; + monitorInfo: Ping; } interface MonitorStatusKey { @@ -26,15 +29,6 @@ interface MonitorStatusKey { location: string; } -const formatBuckets = async ( - buckets: any[], - numTimes: number -): Promise => { - return buckets - .filter((monitor: any) => monitor?.doc_count > numTimes) - .map(({ key, doc_count: count }: any) => ({ ...key, count })); -}; - const getLocationClause = (locations: string[]) => ({ bool: { should: [ @@ -51,10 +45,10 @@ export const getMonitorStatus: UMElasticsearchQueryFn< GetMonitorStatusParams, GetMonitorStatusResult[] > = async ({ callES, dynamicSettings, filters, locations, numTimes, timerange: { from, to } }) => { - const queryResults: Array> = []; let afterKey: MonitorStatusKey | undefined; const STATUS = 'down'; + let monitors: any = []; do { // today this value is hardcoded. In the future we may support // multiple status types for this alert, and this will become a parameter @@ -87,7 +81,7 @@ export const getMonitorStatus: UMElasticsearchQueryFn< size: 2000, sources: [ { - monitor_id: { + monitorId: { terms: { field: 'monitor.id', }, @@ -110,18 +104,20 @@ export const getMonitorStatus: UMElasticsearchQueryFn< }, ], }, + aggs: { + fields: { + top_hits: { + size: 1, + }, + }, + }, }, }, }, }; - /** - * `filters` are an unparsed JSON string. We parse them and append the bool fields of the query - * to the bool of the parsed filters. - */ - if (filters) { - const parsedFilters = JSON.parse(filters); - esParams.body.query.bool = Object.assign({}, esParams.body.query.bool, parsedFilters.bool); + if (filters?.bool) { + esParams.body.query.bool = Object.assign({}, esParams.body.query.bool, filters.bool); } /** @@ -142,8 +138,14 @@ export const getMonitorStatus: UMElasticsearchQueryFn< const result = await callES('search', esParams); afterKey = result?.aggregations?.monitors?.after_key; - queryResults.push(formatBuckets(result?.aggregations?.monitors?.buckets || [], numTimes)); + monitors = monitors.concat(result?.aggregations?.monitors?.buckets || []); } while (afterKey !== undefined); - return (await Promise.all(queryResults)).reduce((acc, cur) => acc.concat(cur), []); + return monitors + .filter((monitor: any) => monitor?.doc_count > numTimes) + .map(({ key, doc_count: count, fields }: any) => ({ + ...key, + count, + monitorInfo: fields?.hits?.hits?.[0]?._source, + })); }; diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index 415b3d2f4b4a1..8fa4561268e8f 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -4,19 +4,36 @@ * you may not use this file except in compliance with the Elastic License. */ -export { getCerts } from './get_certs'; -export { getFilterBar, GetFilterBarParams } from './get_filter_bar'; -export { getUptimeIndexPattern as getIndexPattern } from './get_index_pattern'; -export { getLatestMonitor, GetLatestMonitorParams } from './get_latest_monitor'; -export { getMonitorAvailability } from './get_monitor_availability'; -export { getMonitorDurationChart, GetMonitorChartsParams } from './get_monitor_duration'; -export { getMonitorDetails, GetMonitorDetailsParams } from './get_monitor_details'; -export { getMonitorLocations, GetMonitorLocationsParams } from './get_monitor_locations'; -export { getMonitorStates, GetMonitorStatesParams } from './get_monitor_states'; -export { getMonitorStatus, GetMonitorStatusParams } from './get_monitor_status'; -export * from './get_monitor_status'; -export { getPings } from './get_pings'; -export { getPingHistogram, GetPingHistogramParams } from './get_ping_histogram'; -export { UptimeRequests } from './uptime_requests'; -export { getSnapshotCount, GetSnapshotCountParams } from './get_snapshot_counts'; -export { getIndexStatus } from './get_index_status'; +import { getCerts } from './get_certs'; +import { getFilterBar } from './get_filter_bar'; +import { getUptimeIndexPattern as getIndexPattern } from './get_index_pattern'; +import { getLatestMonitor } from './get_latest_monitor'; +import { getMonitorAvailability } from './get_monitor_availability'; +import { getMonitorDurationChart } from './get_monitor_duration'; +import { getMonitorDetails } from './get_monitor_details'; +import { getMonitorLocations } from './get_monitor_locations'; +import { getMonitorStates } from './get_monitor_states'; +import { getMonitorStatus } from './get_monitor_status'; +import { getPings } from './get_pings'; +import { getPingHistogram } from './get_ping_histogram'; +import { getSnapshotCount } from './get_snapshot_counts'; +import { getIndexStatus } from './get_index_status'; + +export const requests = { + getCerts, + getFilterBar, + getIndexPattern, + getLatestMonitor, + getMonitorAvailability, + getMonitorDurationChart, + getMonitorDetails, + getMonitorLocations, + getMonitorStates, + getMonitorStatus, + getPings, + getPingHistogram, + getSnapshotCount, + getIndexStatus, +}; + +export type UptimeRequests = typeof requests; diff --git a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts b/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts deleted file mode 100644 index 2a9420a275570..0000000000000 --- a/x-pack/plugins/uptime/server/lib/requests/uptime_requests.ts +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { UMElasticsearchQueryFn } from '../adapters'; -import { - OverviewFilters, - GetMonitorAvailabilityParams, - MonitorDetails, - MonitorLocations, - Snapshot, - StatesIndexStatus, - HistogramResult, - Ping, - PingsResponse, - GetCertsParams, - GetPingsParams, - CertResult, - MonitorSummariesResult, -} from '../../../common/runtime_types'; -import { MonitorDurationResult } from '../../../common/types'; - -import { - GetFilterBarParams, - GetLatestMonitorParams, - GetMonitorChartsParams, - GetMonitorDetailsParams, - GetMonitorLocationsParams, - GetMonitorStatesParams, - GetPingHistogramParams, - GetMonitorStatusParams, - GetMonitorStatusResult, -} from '.'; -import { GetSnapshotCountParams } from './get_snapshot_counts'; -import { IIndexPattern } from '../../../../../../src/plugins/data/server'; -import { GetMonitorAvailabilityResult } from './get_monitor_availability'; - -type ESQ = UMElasticsearchQueryFn; - -export interface UptimeRequests { - getCerts: ESQ; - getFilterBar: ESQ; - getIndexPattern: ESQ<{}, IIndexPattern | undefined>; - getLatestMonitor: ESQ; - getMonitorAvailability: ESQ; - getMonitorDurationChart: ESQ; - getMonitorDetails: ESQ; - getMonitorLocations: ESQ; - getMonitorStates: ESQ; - getMonitorStatus: ESQ; - getPings: ESQ; - getPingHistogram: ESQ; - getSnapshotCount: ESQ; - getIndexStatus: ESQ<{}, StatesIndexStatus>; -} From bcf8719824bbc47e482582a13c84e12a39706e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Fri, 14 Aug 2020 08:55:39 +0100 Subject: [PATCH 09/51] Adding API test for custom link transaction example (#74238) * Adding api test for custom link transaction example * expecting specific fields * expecting specific fields Co-authored-by: Elastic Machine --- .../basic/tests/settings/custom_link.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/x-pack/test/apm_api_integration/basic/tests/settings/custom_link.ts b/x-pack/test/apm_api_integration/basic/tests/settings/custom_link.ts index 9465708db2fba..2acc6522bf479 100644 --- a/x-pack/test/apm_api_integration/basic/tests/settings/custom_link.ts +++ b/x-pack/test/apm_api_integration/basic/tests/settings/custom_link.ts @@ -12,6 +12,7 @@ export default function customLinksTests({ getService }: FtrProviderContext) { const supertestRead = getService('supertestAsApmReadUser'); const supertestWrite = getService('supertestAsApmWriteUser'); const log = getService('log'); + const esArchiver = getService('esArchiver'); function searchCustomLinks(filters?: any) { const path = URL.format({ @@ -139,5 +140,18 @@ export default function customLinksTests({ getService }: FtrProviderContext) { expect(status).to.equal(200); expect(body).to.eql([]); }); + + describe('transaction', () => { + before(() => esArchiver.load('8.0.0')); + after(() => esArchiver.unload('8.0.0')); + + it('fetches a transaction sample', async () => { + const response = await supertestRead.get( + '/api/apm/settings/custom_links/transaction?service.name=opbeans-java' + ); + expect(response.status).to.be(200); + expect(response.body.service.name).to.eql('opbeans-java'); + }); + }); }); } From 7bd014abb387a4b6d896abf992c96f2ab98fad65 Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Fri, 14 Aug 2020 12:07:04 +0200 Subject: [PATCH 10/51] [UiActions] pass trigger into action execution context (#74363) Co-authored-by: Elastic Machine --- .../public/actions/actions.tsx | 9 ++- examples/ui_actions_explorer/public/app.tsx | 6 +- .../lib/actions/apply_filter_action.test.ts | 4 + .../public/lib/panel/embeddable_panel.tsx | 7 +- .../add_panel/add_panel_action.test.tsx | 9 ++- .../add_panel/add_panel_action.ts | 10 ++- .../lib/panel/panel_header/panel_header.tsx | 19 +++-- .../test_samples/actions/say_hello_action.tsx | 4 +- .../public/tests/apply_filter_action.test.ts | 3 +- .../ui_actions/public/actions/action.test.ts | 13 +++- .../ui_actions/public/actions/action.ts | 76 +++++++++++++++---- .../build_eui_context_menu_panels.tsx | 53 ++++++++++--- src/plugins/ui_actions/public/index.ts | 7 +- .../service/ui_actions_execution_service.ts | 17 +++-- .../public/service/ui_actions_service.ts | 11 ++- .../tests/execute_trigger_actions.test.ts | 24 +++++- .../public/triggers/default_trigger.ts | 27 +++++++ .../ui_actions/public/triggers/index.ts | 1 + src/plugins/ui_actions/public/types.ts | 9 ++- .../dashboard_to_url_drilldown/index.tsx | 11 ++- .../panel_actions/get_csv_panel_action.tsx | 7 +- .../public/custom_time_range_action.tsx | 7 +- .../public/drilldowns/drilldown_definition.ts | 11 ++- 23 files changed, 277 insertions(+), 68 deletions(-) create mode 100644 src/plugins/ui_actions/public/triggers/default_trigger.ts diff --git a/examples/ui_actions_explorer/public/actions/actions.tsx b/examples/ui_actions_explorer/public/actions/actions.tsx index 6d83362e998bc..777bcd9c18119 100644 --- a/examples/ui_actions_explorer/public/actions/actions.tsx +++ b/examples/ui_actions_explorer/public/actions/actions.tsx @@ -21,7 +21,11 @@ import { OverlayStart } from 'kibana/public'; import { EuiFieldText, EuiModalBody, EuiButton } from '@elastic/eui'; import { useState } from 'react'; import { toMountPoint } from '../../../../src/plugins/kibana_react/public'; -import { createAction, UiActionsStart } from '../../../../src/plugins/ui_actions/public'; +import { + ActionExecutionContext, + createAction, + UiActionsStart, +} from '../../../../src/plugins/ui_actions/public'; export const USER_TRIGGER = 'USER_TRIGGER'; export const COUNTRY_TRIGGER = 'COUNTRY_TRIGGER'; @@ -37,7 +41,8 @@ export const ACTION_SHOWCASE_PLUGGABILITY = 'ACTION_SHOWCASE_PLUGGABILITY'; export const showcasePluggability = createAction({ type: ACTION_SHOWCASE_PLUGGABILITY, getDisplayName: () => 'This is pluggable! Any plugin can inject their actions here.', - execute: async () => alert("Isn't that cool?!"), + execute: async (context: ActionExecutionContext) => + alert(`Isn't that cool?! Triggered by ${context.trigger?.id} trigger`), }); export interface PhoneContext { diff --git a/examples/ui_actions_explorer/public/app.tsx b/examples/ui_actions_explorer/public/app.tsx index 1b0667962a3c2..d59309f006838 100644 --- a/examples/ui_actions_explorer/public/app.tsx +++ b/examples/ui_actions_explorer/public/app.tsx @@ -97,9 +97,9 @@ const ActionsExplorer = ({ uiActionsApi, openModal }: Props) => { }); uiActionsApi.addTriggerAction(HELLO_WORLD_TRIGGER_ID, dynamicAction); setConfirmationText( - `You've successfully added a new action: ${dynamicAction.getDisplayName( - {} - )}. Refresh the page to reset state. It's up to the user of the system to persist state like this.` + `You've successfully added a new action: ${dynamicAction.getDisplayName({ + trigger: uiActionsApi.getTrigger(HELLO_WORLD_TRIGGER_ID), + })}. Refresh the page to reset state. It's up to the user of the system to persist state like this.` ); }} > diff --git a/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts b/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts index 636ce3e623c5b..88c1a5917e609 100644 --- a/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts +++ b/src/plugins/embeddable/public/lib/actions/apply_filter_action.test.ts @@ -19,6 +19,7 @@ import { createFilterAction } from './apply_filter_action'; import { expectErrorAsync } from '../../tests/helpers'; +import { defaultTrigger } from '../../../../ui_actions/public/triggers'; test('has ACTION_APPLY_FILTER type and id', () => { const action = createFilterAction(); @@ -51,6 +52,7 @@ describe('isCompatible()', () => { }), } as any, filters: [], + trigger: defaultTrigger, }); expect(result).toBe(true); }); @@ -66,6 +68,7 @@ describe('isCompatible()', () => { }), } as any, filters: [], + trigger: defaultTrigger, }); expect(result).toBe(false); }); @@ -119,6 +122,7 @@ describe('execute()', () => { await action.execute({ embeddable, filters: ['FILTER' as any], + trigger: defaultTrigger, }); expect(root.updateInput).toHaveBeenCalledTimes(1); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index cb02ffc470e95..d8659680dceb9 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -30,6 +30,7 @@ import { PANEL_BADGE_TRIGGER, PANEL_NOTIFICATION_TRIGGER, EmbeddableContext, + contextMenuTrigger, } from '../triggers'; import { IEmbeddable, EmbeddableOutput, EmbeddableError } from '../embeddables/i_embeddable'; import { ViewMode } from '../types'; @@ -311,7 +312,11 @@ export class EmbeddablePanel extends React.Component { const sortedActions = [...regularActions, ...extraActions].sort(sortByOrderField); return await buildContextMenuForActions({ - actions: sortedActions.map((action) => [action, { embeddable: this.props.embeddable }]), + actions: sortedActions.map((action) => ({ + action, + context: { embeddable: this.props.embeddable }, + trigger: contextMenuTrigger, + })), closeMenu: this.closeMyContextMenuPanel, }); }; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx index d8def3147e52c..0361939fd07e6 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.test.tsx @@ -31,6 +31,7 @@ import { ContactCardEmbeddable } from '../../../../test_samples'; import { esFilters, Filter } from '../../../../../../../../plugins/data/public'; import { EmbeddableStart } from '../../../../../plugin'; import { embeddablePluginMock } from '../../../../../mocks'; +import { defaultTrigger } from '../../../../../../../ui_actions/public/triggers'; const { setup, doStart } = embeddablePluginMock.createInstance(); setup.registerEmbeddableFactory(FILTERABLE_EMBEDDABLE, new FilterableEmbeddableFactory()); @@ -85,7 +86,9 @@ test('Is not compatible when container is in view mode', async () => { () => null ); container.updateInput({ viewMode: ViewMode.VIEW }); - expect(await addPanelAction.isCompatible({ embeddable: container })).toBe(false); + expect( + await addPanelAction.isCompatible({ embeddable: container, trigger: defaultTrigger }) + ).toBe(false); }); test('Is not compatible when embeddable is not a container', async () => { @@ -94,7 +97,7 @@ test('Is not compatible when embeddable is not a container', async () => { test('Is compatible when embeddable is a parent and in edit mode', async () => { container.updateInput({ viewMode: ViewMode.EDIT }); - expect(await action.isCompatible({ embeddable: container })).toBe(true); + expect(await action.isCompatible({ embeddable: container, trigger: defaultTrigger })).toBe(true); }); test('Execute throws an error when called with an embeddable that is not a container', async () => { @@ -108,6 +111,7 @@ test('Execute throws an error when called with an embeddable that is not a conta }, {} as any ), + trigger: defaultTrigger, } as any); } await expect(check()).rejects.toThrow(Error); @@ -116,6 +120,7 @@ test('Execute does not throw an error when called with a compatible container', container.updateInput({ viewMode: ViewMode.EDIT }); await action.execute({ embeddable: container, + trigger: defaultTrigger, }); }); diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts index f3a483bb4bda4..63575273bbf62 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_action.ts @@ -17,7 +17,7 @@ * under the License. */ import { i18n } from '@kbn/i18n'; -import { Action } from 'src/plugins/ui_actions/public'; +import { Action, ActionExecutionContext } from 'src/plugins/ui_actions/public'; import { NotificationsStart, OverlayStart } from 'src/core/public'; import { EmbeddableStart } from 'src/plugins/embeddable/public/plugin'; import { ViewMode } from '../../../../types'; @@ -52,12 +52,14 @@ export class AddPanelAction implements Action { return 'plusInCircleFilled'; } - public async isCompatible({ embeddable }: ActionContext) { + public async isCompatible(context: ActionExecutionContext) { + const { embeddable } = context; return embeddable.getIsContainer() && embeddable.getInput().viewMode === ViewMode.EDIT; } - public async execute({ embeddable }: ActionContext) { - if (!embeddable.getIsContainer() || !(await this.isCompatible({ embeddable }))) { + public async execute(context: ActionExecutionContext) { + const { embeddable } = context; + if (!embeddable.getIsContainer() || !(await this.isCompatible(context))) { throw new Error('Context is incompatible'); } diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx index 2f086a3fb2c0c..5d7daaa7217ed 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_header.tsx @@ -30,7 +30,7 @@ import React from 'react'; import { Action } from 'src/plugins/ui_actions/public'; import { PanelOptionsMenu } from './panel_options_menu'; import { IEmbeddable } from '../../embeddables'; -import { EmbeddableContext } from '../../triggers'; +import { EmbeddableContext, panelBadgeTrigger, panelNotificationTrigger } from '../../triggers'; export interface PanelHeaderProps { title?: string; @@ -49,11 +49,11 @@ function renderBadges(badges: Array>, embeddable: IEmb badge.execute({ embeddable })} - onClickAriaLabel={badge.getDisplayName({ embeddable })} + iconType={badge.getIconType({ embeddable, trigger: panelBadgeTrigger })} + onClick={() => badge.execute({ embeddable, trigger: panelBadgeTrigger })} + onClickAriaLabel={badge.getDisplayName({ embeddable, trigger: panelBadgeTrigger })} > - {badge.getDisplayName({ embeddable })} + {badge.getDisplayName({ embeddable, trigger: panelBadgeTrigger })} )); } @@ -70,14 +70,17 @@ function renderNotifications( data-test-subj={`embeddablePanelNotification-${notification.id}`} key={notification.id} style={{ marginTop: '4px', marginRight: '4px' }} - onClick={() => notification.execute(context)} + onClick={() => notification.execute({ ...context, trigger: panelNotificationTrigger })} > - {notification.getDisplayName(context)} + {notification.getDisplayName({ ...context, trigger: panelNotificationTrigger })} ); if (notification.getDisplayNameTooltip) { - const tooltip = notification.getDisplayNameTooltip(context); + const tooltip = notification.getDisplayNameTooltip({ + ...context, + trigger: panelNotificationTrigger, + }); if (tooltip) { badge = ( diff --git a/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx b/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx index 0612b838a6ce7..968caf67b1826 100644 --- a/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx +++ b/src/plugins/embeddable/public/lib/test_samples/actions/say_hello_action.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { ActionByType, IncompatibleActionError, ActionType } from '../../ui_actions'; +import { IncompatibleActionError, ActionType, ActionDefinitionByType } from '../../ui_actions'; import { EmbeddableInput, Embeddable, EmbeddableOutput, IEmbeddable } from '../../embeddables'; // Casting to ActionType is a hack - in a real situation use @@ -42,7 +42,7 @@ export interface SayHelloActionContext { message?: string; } -export class SayHelloAction implements ActionByType { +export class SayHelloAction implements ActionDefinitionByType { public readonly type = SAY_HELLO_ACTION; public readonly id = SAY_HELLO_ACTION; diff --git a/src/plugins/embeddable/public/tests/apply_filter_action.test.ts b/src/plugins/embeddable/public/tests/apply_filter_action.test.ts index 9d765c9906443..f8c4a4a7e4b72 100644 --- a/src/plugins/embeddable/public/tests/apply_filter_action.test.ts +++ b/src/plugins/embeddable/public/tests/apply_filter_action.test.ts @@ -31,6 +31,7 @@ import { FilterableEmbeddableInput, } from '../lib/test_samples'; import { esFilters } from '../../../data/public'; +import { applyFilterTrigger } from '../../../ui_actions/public'; test('ApplyFilterAction applies the filter to the root of the container tree', async () => { const { doStart, setup } = testPlugin(); @@ -85,7 +86,7 @@ test('ApplyFilterAction applies the filter to the root of the container tree', a query: { match: { extension: { query: 'foo' } } }, }; - await applyFilterAction.execute({ embeddable, filters: [filter] }); + await applyFilterAction.execute({ embeddable, filters: [filter], trigger: applyFilterTrigger }); expect(root.getInput().filters.length).toBe(1); expect(node1.getInput().filters.length).toBe(1); expect(embeddable.getInput().filters.length).toBe(1); diff --git a/src/plugins/ui_actions/public/actions/action.test.ts b/src/plugins/ui_actions/public/actions/action.test.ts index f9d696d3ddb5f..1f76223a0d7c4 100644 --- a/src/plugins/ui_actions/public/actions/action.test.ts +++ b/src/plugins/ui_actions/public/actions/action.test.ts @@ -17,8 +17,9 @@ * under the License. */ -import { createAction } from '../../../ui_actions/public'; +import { ActionExecutionContext, createAction } from '../../../ui_actions/public'; import { ActionType } from '../types'; +import { defaultTrigger } from '../triggers'; const sayHelloAction = createAction({ // Casting to ActionType is a hack - in a real situation use @@ -29,11 +30,17 @@ const sayHelloAction = createAction({ }); test('action is not compatible based on context', async () => { - const isCompatible = await sayHelloAction.isCompatible({ amICompatible: false }); + const isCompatible = await sayHelloAction.isCompatible({ + amICompatible: false, + trigger: defaultTrigger, + } as ActionExecutionContext); expect(isCompatible).toBe(false); }); test('action is compatible based on context', async () => { - const isCompatible = await sayHelloAction.isCompatible({ amICompatible: true }); + const isCompatible = await sayHelloAction.isCompatible({ + amICompatible: true, + trigger: defaultTrigger, + } as ActionExecutionContext); expect(isCompatible).toBe(true); }); diff --git a/src/plugins/ui_actions/public/actions/action.ts b/src/plugins/ui_actions/public/actions/action.ts index bc5f36acb8f0c..8005dadd8f5ef 100644 --- a/src/plugins/ui_actions/public/actions/action.ts +++ b/src/plugins/ui_actions/public/actions/action.ts @@ -18,13 +18,43 @@ */ import { UiComponent } from 'src/plugins/kibana_utils/public'; -import { ActionType, ActionContextMapping } from '../types'; +import { ActionType, ActionContextMapping, BaseContext } from '../types'; import { Presentable } from '../util/presentable'; +import { Trigger } from '../triggers'; export type ActionByType = Action; +export type ActionDefinitionByType = ActionDefinition< + ActionContextMapping[T] +>; -export interface Action - extends Partial> { +/** + * During action execution we can provide additional information, + * for example, trigger, that caused the action execution + */ +export interface ActionExecutionMeta { + /** + * Trigger that executed the action + */ + trigger: Trigger; +} + +/** + * Action methods are executed with Context from trigger + {@link ActionExecutionMeta} + */ +export type ActionExecutionContext = Context & + ActionExecutionMeta; + +/** + * Simplified action context for {@link ActionDefinition} + * When defining action consumer may use either it's own Context + * or an ActionExecutionContext to get access to {@link ActionExecutionMeta} params + */ +export type ActionDefinitionContext = + | Context + | ActionExecutionContext; + +export interface Action + extends Partial>> { /** * Determined the order when there is more than one action matched to a trigger. * Higher numbers are displayed first. @@ -44,44 +74,51 @@ export interface Action /** * Optional EUI icon type that can be displayed along with the title. */ - getIconType(context: Context): string | undefined; + getIconType(context: ActionExecutionContext): string | undefined; /** * Returns a title to be displayed to the user. * @param context */ - getDisplayName(context: Context): string; + getDisplayName(context: ActionExecutionContext): string; /** * `UiComponent` to render when displaying this action as a context menu item. * If not provided, `getDisplayName` will be used instead. */ - MenuItem?: UiComponent<{ context: Context }>; + MenuItem?: UiComponent<{ context: ActionExecutionContext }>; /** * Returns a promise that resolves to true if this action is compatible given the context, * otherwise resolves to false. */ - isCompatible(context: Context): Promise; + isCompatible(context: ActionExecutionContext): Promise; /** * Executes the action. */ - execute(context: Context): Promise; + execute(context: ActionExecutionContext): Promise; + + /** + * This method should return a link if this item can be clicked on. The link + * is used to navigate user if user middle-clicks it or Ctrl + clicks or + * right-clicks and selects "Open in new tab". + */ + getHref?(context: ActionExecutionContext): Promise; /** * Determines if action should be executed automatically, * without first showing up in context menu. * false by default. */ - shouldAutoExecute?(context: Context): Promise; + shouldAutoExecute?(context: ActionExecutionContext): Promise; } /** * A convenience interface used to register an action. */ -export interface ActionDefinition - extends Partial> { +export interface ActionDefinition + extends Partial>> { /** * ID of the action that uniquely identifies this action in the actions registry. */ @@ -92,17 +129,30 @@ export interface ActionDefinition */ readonly type?: ActionType; + /** + * Returns a promise that resolves to true if this item is compatible given + * the context and should be displayed to user, otherwise resolves to false. + */ + isCompatible?(context: ActionDefinitionContext): Promise; + /** * Executes the action. */ - execute(context: Context): Promise; + execute(context: ActionDefinitionContext): Promise; /** * Determines if action should be executed automatically, * without first showing up in context menu. * false by default. */ - shouldAutoExecute?(context: Context): Promise; + shouldAutoExecute?(context: ActionDefinitionContext): Promise; + + /** + * This method should return a link if this item can be clicked on. The link + * is used to navigate user if user middle-clicks it or Ctrl + clicks or + * right-clicks and selects "Open in new tab". + */ + getHref?(context: ActionDefinitionContext): Promise; } export type ActionContext = A extends ActionDefinition ? Context : never; diff --git a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx index 7b87a5992a7f5..b44a07273f4a9 100644 --- a/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx +++ b/src/plugins/ui_actions/public/context_menu/build_eui_context_menu_panels.tsx @@ -23,13 +23,22 @@ import _ from 'lodash'; import { i18n } from '@kbn/i18n'; import { uiToReactComponent } from '../../../kibana_react/public'; import { Action } from '../actions'; +import { Trigger } from '../triggers'; import { BaseContext } from '../types'; export const defaultTitle = i18n.translate('uiActions.actionPanel.title', { defaultMessage: 'Options', }); -type ActionWithContext = [Action, Context]; +interface ActionWithContext { + action: Action; + context: Context; + + /** + * Trigger that caused this action + */ + trigger: Trigger; +} /** * Transforms an array of Actions to the shape EuiContextMenuPanel expects. @@ -66,15 +75,19 @@ async function buildEuiContextMenuPanelItems({ closeMenu: () => void; }) { const items: EuiContextMenuPanelItemDescriptor[] = new Array(actions.length); - const promises = actions.map(async ([action, actionContext], index) => { - const isCompatible = await action.isCompatible(actionContext); + const promises = actions.map(async ({ action, context, trigger }, index) => { + const isCompatible = await action.isCompatible({ + ...context, + trigger, + }); if (!isCompatible) { return; } items[index] = await convertPanelActionToContextMenuItem({ action, - actionContext, + actionContext: context, + trigger, closeMenu, }); }); @@ -87,19 +100,30 @@ async function buildEuiContextMenuPanelItems({ async function convertPanelActionToContextMenuItem({ action, actionContext, + trigger, closeMenu, }: { action: Action; actionContext: Context; + trigger: Trigger; closeMenu: () => void; }): Promise { const menuPanelItem: EuiContextMenuPanelItemDescriptor = { name: action.MenuItem ? React.createElement(uiToReactComponent(action.MenuItem), { - context: actionContext, + context: { + ...actionContext, + trigger, + }, }) - : action.getDisplayName(actionContext), - icon: action.getIconType(actionContext), + : action.getDisplayName({ + ...actionContext, + trigger, + }), + icon: action.getIconType({ + ...actionContext, + trigger, + }), panel: _.get(action, 'childContextMenuPanel.id'), 'data-test-subj': `embeddablePanelAction-${action.id}`, }; @@ -114,20 +138,29 @@ async function convertPanelActionToContextMenuItem({ !(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) // ignore clicks with modifier keys ) { event.preventDefault(); - action.execute(actionContext); + action.execute({ + ...actionContext, + trigger, + }); } else { // let browser handle navigation } } else { // not a link - action.execute(actionContext); + action.execute({ + ...actionContext, + trigger, + }); } closeMenu(); }; if (action.getHref) { - const href = await action.getHref(actionContext); + const href = await action.getHref({ + ...actionContext, + trigger, + }); if (href) { menuPanelItem.href = href; } diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index a9b413fb36542..d76ca124ead2c 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -45,4 +45,9 @@ export { applyFilterTrigger, } from './triggers'; export { TriggerContextMapping, TriggerId, ActionContextMapping, ActionType } from './types'; -export { ActionByType } from './actions'; +export { + ActionByType, + ActionDefinitionByType, + ActionExecutionContext, + ActionExecutionMeta, +} from './actions'; diff --git a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts index 7393989672e9d..df89c9c2f70e9 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts @@ -46,7 +46,7 @@ export class UiActionsExecutionService { context: BaseContext; trigger: Trigger; }): Promise { - const shouldBatch = !(await action.shouldAutoExecute?.(context)) ?? false; + const shouldBatch = !(await action.shouldAutoExecute?.({ ...context, trigger })) ?? false; const task: ExecuteActionTask = { action, context, @@ -59,7 +59,7 @@ export class UiActionsExecutionService { } else { this.pendingTasks.add(task); try { - await action.execute(context); + await action.execute({ ...context, trigger }); this.pendingTasks.delete(task); } catch (e) { this.pendingTasks.delete(task); @@ -96,9 +96,12 @@ export class UiActionsExecutionService { }, 0); } - private async executeSingleTask({ context, action, defer }: ExecuteActionTask) { + private async executeSingleTask({ context, action, defer, trigger }: ExecuteActionTask) { try { - await action.execute(context); + await action.execute({ + ...context, + trigger, + }); defer.resolve(); } catch (e) { defer.reject(e); @@ -107,7 +110,11 @@ export class UiActionsExecutionService { private async executeMultipleActions(tasks: ExecuteActionTask[]) { const panel = await buildContextMenuForActions({ - actions: tasks.map(({ action, context }) => [action, context]), + actions: tasks.map(({ action, context, trigger }) => ({ + action, + context, + trigger, + })), title: tasks[0].trigger.title, // title of context menu is title of trigger which originated the chain closeMenu: () => { tasks.forEach((t) => t.defer.resolve()); diff --git a/src/plugins/ui_actions/public/service/ui_actions_service.ts b/src/plugins/ui_actions/public/service/ui_actions_service.ts index 08efffbb6b5a8..6028177964fb7 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_service.ts @@ -142,7 +142,7 @@ export class UiActionsService { triggerId: T, // The action can accept partial or no context, but if it needs context not provided // by this type of trigger, typescript will complain. yay! - action: Action + action: ActionDefinition | Action // TODO: remove `Action` https://github.com/elastic/kibana/issues/74501 ): void => { if (!this.actions.has(action.id)) this.registerAction(action); this.attachAction(triggerId, action.id); @@ -178,7 +178,14 @@ export class UiActionsService { context: TriggerContextMapping[T] ): Promise>> => { const actions = this.getTriggerActions!(triggerId); - const isCompatibles = await Promise.all(actions.map((action) => action.isCompatible(context))); + const isCompatibles = await Promise.all( + actions.map((action) => + action.isCompatible({ + ...context, + trigger: this.getTrigger(triggerId), + }) + ) + ); return actions.reduce( (acc: Array>, action, i) => isCompatibles[i] ? [...acc, action] : acc, diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index 9af46f25b4fec..81120990001e3 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -82,7 +82,7 @@ test('executes a single action mapped to a trigger', async () => { jest.runAllTimers(); expect(executeFn).toBeCalledTimes(1); - expect(executeFn).toBeCalledWith(context); + expect(executeFn).toBeCalledWith(expect.objectContaining(context)); }); test('throws an error if there are no compatible actions to execute', async () => { @@ -202,3 +202,25 @@ test("doesn't show a context menu for auto executable actions", async () => { expect(openContextMenu).toHaveBeenCalledTimes(0); }); }); + +test('passes trigger into execute', async () => { + const { setup, doStart } = uiActions; + const trigger = { + id: 'MY-TRIGGER' as TriggerId, + title: 'My trigger', + }; + const action = createTestAction<{ foo: string }>('test', () => true); + + setup.registerTrigger(trigger); + setup.addTriggerAction(trigger.id, action); + + const start = doStart(); + + const context = { foo: 'bar' }; + await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + jest.runAllTimers(); + expect(executeFn).toBeCalledWith({ + ...context, + trigger, + }); +}); diff --git a/src/plugins/ui_actions/public/triggers/default_trigger.ts b/src/plugins/ui_actions/public/triggers/default_trigger.ts new file mode 100644 index 0000000000000..74be0243bdac5 --- /dev/null +++ b/src/plugins/ui_actions/public/triggers/default_trigger.ts @@ -0,0 +1,27 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Trigger } from '.'; + +export const DEFAULT_TRIGGER = ''; +export const defaultTrigger: Trigger<''> = { + id: DEFAULT_TRIGGER, + title: 'Unknown', + description: 'Unknown trigger.', +}; diff --git a/src/plugins/ui_actions/public/triggers/index.ts b/src/plugins/ui_actions/public/triggers/index.ts index a5bf9e1822941..dbc54163c5af5 100644 --- a/src/plugins/ui_actions/public/triggers/index.ts +++ b/src/plugins/ui_actions/public/triggers/index.ts @@ -23,3 +23,4 @@ export * from './trigger_internal'; export * from './select_range_trigger'; export * from './value_click_trigger'; export * from './apply_filter_trigger'; +export * from './default_trigger'; diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 5631441cf9a1b..dcf0bfb14d538 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -19,7 +19,12 @@ import { ActionInternal } from './actions/action_internal'; import { TriggerInternal } from './triggers/trigger_internal'; -import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, APPLY_FILTER_TRIGGER } from './triggers'; +import { + SELECT_RANGE_TRIGGER, + VALUE_CLICK_TRIGGER, + APPLY_FILTER_TRIGGER, + DEFAULT_TRIGGER, +} from './triggers'; import type { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; import type { ApplyGlobalFilterActionContext } from '../../data/public'; @@ -27,8 +32,6 @@ export type TriggerRegistry = Map>; export type ActionRegistry = Map; export type TriggerToActionsRegistry = Map; -const DEFAULT_TRIGGER = ''; - export type TriggerId = keyof TriggerContextMapping; export type BaseContext = object; diff --git a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx index 037e017097e53..67599687dd881 100644 --- a/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx +++ b/x-pack/examples/ui_actions_enhanced_examples/public/dashboard_to_url_drilldown/index.tsx @@ -10,6 +10,7 @@ import { reactToUiComponent } from '../../../../../src/plugins/kibana_react/publ import { UiActionsEnhancedDrilldownDefinition as Drilldown } from '../../../../plugins/ui_actions_enhanced/public'; import { ChartActionContext } from '../../../../../src/plugins/embeddable/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../src/plugins/kibana_utils/public'; +import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public'; function isValidUrl(url: string) { try { @@ -101,7 +102,15 @@ export class DashboardToUrlDrilldown implements Drilldown return config.url; }; - public readonly execute = async (config: Config, context: ActionContext) => { + public readonly execute = async ( + config: Config, + context: ActionExecutionContext + ) => { + // Just for showcasing: + // we can get trigger a which caused this drilldown execution + // eslint-disable-next-line no-console + console.log(context.trigger?.id); + const url = await this.getHref(config, context); if (config.openInNewTab) { diff --git a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx index d0800c7b24fef..30025dce18c0b 100644 --- a/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx +++ b/x-pack/plugins/reporting/public/panel_actions/get_csv_panel_action.tsx @@ -8,7 +8,10 @@ import { i18n } from '@kbn/i18n'; import _ from 'lodash'; import moment from 'moment-timezone'; import { CoreSetup } from 'src/core/public'; -import { Action, IncompatibleActionError } from '../../../../../src/plugins/ui_actions/public'; +import { + UiActionsActionDefinition as ActionDefinition, + IncompatibleActionError, +} from '../../../../../src/plugins/ui_actions/public'; import { LicensingPluginSetup } from '../../../licensing/public'; import { checkLicense } from '../lib/license_check'; @@ -30,7 +33,7 @@ interface ActionContext { embeddable: ISearchEmbeddable; } -export class GetCsvReportPanelAction implements Action { +export class GetCsvReportPanelAction implements ActionDefinition { private isDownloading: boolean; public readonly type = ''; public readonly id = CSV_REPORTING_ACTION; diff --git a/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx b/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx index 5d9804d2a5c33..259fe5c774c4b 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx +++ b/x-pack/plugins/ui_actions_enhanced/public/custom_time_range_action.tsx @@ -7,7 +7,10 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { IEmbeddable, Embeddable, EmbeddableInput } from 'src/plugins/embeddable/public'; -import { ActionByType, IncompatibleActionError } from '../../../../src/plugins/ui_actions/public'; +import { + ActionDefinitionByType, + IncompatibleActionError, +} from '../../../../src/plugins/ui_actions/public'; import { TimeRange } from '../../../../src/plugins/data/public'; import { CustomizeTimeRangeModal } from './customize_time_range_modal'; import { OpenModal, CommonlyUsedRange } from './types'; @@ -38,7 +41,7 @@ export interface TimeRangeActionContext { embeddable: Embeddable; } -export class CustomTimeRangeAction implements ActionByType { +export class CustomTimeRangeAction implements ActionDefinitionByType { public readonly type = CUSTOM_TIME_RANGE; private openModal: OpenModal; private dateFormat?: string; diff --git a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts index a41ae851e185b..756bdf9e672aa 100644 --- a/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts +++ b/x-pack/plugins/ui_actions_enhanced/public/drilldowns/drilldown_definition.ts @@ -6,6 +6,7 @@ import { ActionFactoryDefinition } from '../dynamic_actions'; import { LicenseType } from '../../../licensing/public'; +import { ActionExecutionContext } from '../../../../../src/plugins/ui_actions/public'; /** * This is a convenience interface to register a drilldown. Drilldown has @@ -93,10 +94,16 @@ export interface DrilldownDefinition< * @param context Object that represents context in which the underlying * `UIAction` of this drilldown is being executed in. */ - execute(config: Config, context: ExecutionContext): void; + execute( + config: Config, + context: ExecutionContext | ActionExecutionContext + ): void; /** * A link where drilldown should navigate on middle click or Ctrl + click. */ - getHref?(config: Config, context: ExecutionContext): Promise; + getHref?( + config: Config, + context: ExecutionContext | ActionExecutionContext + ): Promise; } From 67e28ac8b45df85c18fe71902833a0c5bd36fe2d Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Fri, 14 Aug 2020 08:34:26 -0400 Subject: [PATCH 11/51] [EventLog] Populate alert instances view with event log data (#68437) resolves https://github.com/elastic/kibana/issues/57446 Adds a new API (AlertClient and HTTP endpoint) `getAlertStatus()` which returns alert data calculated from the event log. --- x-pack/plugins/alerts/README.md | 18 + x-pack/plugins/alerts/common/alert_status.ts | 31 ++ x-pack/plugins/alerts/common/index.ts | 1 + .../alerts/server/alerts_client.mock.ts | 1 + .../alerts/server/alerts_client.test.ts | 246 +++++++++- x-pack/plugins/alerts/server/alerts_client.ts | 80 ++- .../server/alerts_client_factory.test.ts | 4 + .../alerts/server/alerts_client_factory.ts | 9 +- .../authorization/alerts_authorization.ts | 1 + .../lib/alert_status_from_event_log.test.ts | 464 ++++++++++++++++++ .../server/lib/alert_status_from_event_log.ts | 123 +++++ .../server/lib/iso_or_relative_date.test.ts | 28 ++ .../alerts/server/lib/iso_or_relative_date.ts | 27 + x-pack/plugins/alerts/server/plugin.ts | 9 +- .../server/routes/get_alert_status.test.ts | 105 ++++ .../alerts/server/routes/get_alert_status.ts | 52 ++ x-pack/plugins/alerts/server/routes/index.ts | 1 + .../server/task_runner/task_runner.test.ts | 67 ++- .../alerts/server/task_runner/task_runner.ts | 30 +- .../server/event_log_start_service.test.ts | 8 + x-pack/plugins/event_log/server/index.ts | 3 + x-pack/plugins/event_log/server/mocks.ts | 2 + x-pack/plugins/event_log/server/types.ts | 1 + .../alerting.test.ts | 4 + .../feature_privilege_builder/alerting.ts | 2 +- .../public/application/lib/alert_api.ts | 12 +- .../components/alert_instances.test.tsx | 149 +++--- .../components/alert_instances.tsx | 49 +- .../components/alert_instances_route.test.tsx | 75 +-- .../components/alert_instances_route.tsx | 30 +- .../with_bulk_alert_api_operations.tsx | 11 +- .../triggers_actions_ui/public/types.ts | 12 +- .../plugins/alerts/server/alert_types.ts | 22 +- .../common/lib/get_event_log.ts | 2 +- .../tests/alerting/get_alert_status.ts | 202 ++++++++ .../tests/alerting/index.ts | 1 + .../spaces_only/tests/alerting/event_log.ts | 35 +- .../tests/alerting/get_alert_status.ts | 261 ++++++++++ .../spaces_only/tests/alerting/index.ts | 1 + .../apps/triggers_actions_ui/details.ts | 20 +- .../services/alerting/alerts.ts | 19 +- 41 files changed, 2012 insertions(+), 206 deletions(-) create mode 100644 x-pack/plugins/alerts/common/alert_status.ts create mode 100644 x-pack/plugins/alerts/server/lib/alert_status_from_event_log.test.ts create mode 100644 x-pack/plugins/alerts/server/lib/alert_status_from_event_log.ts create mode 100644 x-pack/plugins/alerts/server/lib/iso_or_relative_date.test.ts create mode 100644 x-pack/plugins/alerts/server/lib/iso_or_relative_date.ts create mode 100644 x-pack/plugins/alerts/server/routes/get_alert_status.test.ts create mode 100644 x-pack/plugins/alerts/server/routes/get_alert_status.ts create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/get_alert_status.ts create mode 100644 x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_status.ts diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index 10568abbe3c72..aab05cb0a7cfd 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -26,6 +26,7 @@ Table of Contents - [`GET /api/alerts/_find`: Find alerts](#get-apialertfind-find-alerts) - [`GET /api/alerts/alert/{id}`: Get alert](#get-apialertid-get-alert) - [`GET /api/alerts/alert/{id}/state`: Get alert state](#get-apialertidstate-get-alert-state) + - [`GET /api/alerts/alert/{id}/status`: Get alert status](#get-apialertidstate-get-alert-status) - [`GET /api/alerts/list_alert_types`: List alert types](#get-apialerttypes-list-alert-types) - [`PUT /api/alerts/alert/{id}`: Update alert](#put-apialertid-update-alert) - [`POST /api/alerts/alert/{id}/_enable`: Enable an alert](#post-apialertidenable-enable-an-alert) @@ -504,6 +505,23 @@ Params: |---|---|---| |id|The id of the alert whose state you're trying to get.|string| +### `GET /api/alerts/alert/{id}/status`: Get alert status + +Similar to the `GET state` call, but collects additional information from +the event log. + +Params: + +|Property|Description|Type| +|---|---|---| +|id|The id of the alert whose status you're trying to get.|string| + +Query: + +|Property|Description|Type| +|---|---|---| +|dateStart|The date to start looking for alert events in the event log. Either an ISO date string, or a duration string indicating time since now.|string| + ### `GET /api/alerts/list_alert_types`: List alert types No parameters. diff --git a/x-pack/plugins/alerts/common/alert_status.ts b/x-pack/plugins/alerts/common/alert_status.ts new file mode 100644 index 0000000000000..517db6d6cb243 --- /dev/null +++ b/x-pack/plugins/alerts/common/alert_status.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +type AlertStatusValues = 'OK' | 'Active' | 'Error'; +type AlertInstanceStatusValues = 'OK' | 'Active'; + +export interface AlertStatus { + id: string; + name: string; + tags: string[]; + alertTypeId: string; + consumer: string; + muteAll: boolean; + throttle: string | null; + enabled: boolean; + statusStartDate: string; + statusEndDate: string; + status: AlertStatusValues; + lastRun?: string; + errorMessages: Array<{ date: string; message: string }>; + instances: Record; +} + +export interface AlertInstanceStatus { + status: AlertInstanceStatusValues; + muted: boolean; + activeStartDate?: string; +} diff --git a/x-pack/plugins/alerts/common/index.ts b/x-pack/plugins/alerts/common/index.ts index b839c07a9db89..0922e164a3aa3 100644 --- a/x-pack/plugins/alerts/common/index.ts +++ b/x-pack/plugins/alerts/common/index.ts @@ -9,6 +9,7 @@ export * from './alert_type'; export * from './alert_instance'; export * from './alert_task_instance'; export * from './alert_navigation'; +export * from './alert_status'; export interface ActionGroup { id: string; diff --git a/x-pack/plugins/alerts/server/alerts_client.mock.ts b/x-pack/plugins/alerts/server/alerts_client.mock.ts index be70e441b6fc5..b61139ae72c99 100644 --- a/x-pack/plugins/alerts/server/alerts_client.mock.ts +++ b/x-pack/plugins/alerts/server/alerts_client.mock.ts @@ -25,6 +25,7 @@ const createAlertsClientMock = () => { muteInstance: jest.fn(), unmuteInstance: jest.fn(), listAlertTypes: jest.fn(), + getAlertStatus: jest.fn(), }; return mocked; }; diff --git a/x-pack/plugins/alerts/server/alerts_client.test.ts b/x-pack/plugins/alerts/server/alerts_client.test.ts index c25e040ad09ce..d994269366ae6 100644 --- a/x-pack/plugins/alerts/server/alerts_client.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client.test.ts @@ -11,16 +11,22 @@ import { taskManagerMock } from '../../task_manager/server/task_manager.mock'; import { alertTypeRegistryMock } from './alert_type_registry.mock'; import { alertsAuthorizationMock } from './authorization/alerts_authorization.mock'; import { TaskStatus } from '../../task_manager/server'; -import { IntervalSchedule } from './types'; +import { IntervalSchedule, RawAlert } from './types'; import { resolvable } from './test_utils'; import { encryptedSavedObjectsMock } from '../../encrypted_saved_objects/server/mocks'; import { actionsClientMock, actionsAuthorizationMock } from '../../actions/server/mocks'; import { AlertsAuthorization } from './authorization/alerts_authorization'; import { ActionsAuthorization } from '../../actions/server'; +import { eventLogClientMock } from '../../event_log/server/mocks'; +import { QueryEventsBySavedObjectResult } from '../../event_log/server'; +import { SavedObject } from 'kibana/server'; +import { EventsFactory } from './lib/alert_status_from_event_log.test'; const taskManager = taskManagerMock.start(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); +const eventLogClient = eventLogClientMock.create(); + const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); @@ -39,6 +45,7 @@ const alertsClientParams: jest.Mocked = { logger: loggingSystemMock.create().get(), encryptedSavedObjectsClient: encryptedSavedObjects, getActionsClient: jest.fn(), + getEventLogClient: jest.fn(), }; beforeEach(() => { @@ -91,17 +98,33 @@ beforeEach(() => { async executor() {}, producer: 'alerts', })); + alertsClientParams.getEventLogClient.mockResolvedValue(eventLogClient); }); -const mockedDate = new Date('2019-02-12T21:01:22.479Z'); -// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockedDateString = '2019-02-12T21:01:22.479Z'; +const mockedDate = new Date(mockedDateString); +const DateOriginal = Date; + +// A version of date that responds to `new Date(null|undefined)` and `Date.now()` +// by returning a fixed date, otherwise should be same as Date. +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ (global as any).Date = class Date { - constructor() { - return mockedDate; + constructor(...args: unknown[]) { + // sometimes the ctor has no args, sometimes has a single `null` arg + if (args[0] == null) { + // @ts-ignore + return mockedDate; + } else { + // @ts-ignore + return new DateOriginal(...args); + } } static now() { return mockedDate.getTime(); } + static parse(string: string) { + return DateOriginal.parse(string); + } }; function getMockData(overwrites: Record = {}): CreateOptions['data'] { @@ -2295,6 +2318,219 @@ describe('getAlertState()', () => { }); }); +const AlertStatusFindEventsResult: QueryEventsBySavedObjectResult = { + page: 1, + per_page: 10000, + total: 0, + data: [], +}; + +const AlertStatusIntervalSeconds = 1; + +const BaseAlertStatusSavedObject: SavedObject = { + id: '1', + type: 'alert', + attributes: { + enabled: true, + name: 'alert-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: '123', + consumer: 'alert-consumer', + schedule: { interval: `${AlertStatusIntervalSeconds}s` }, + actions: [], + params: {}, + createdBy: null, + updatedBy: null, + createdAt: mockedDateString, + apiKey: null, + apiKeyOwner: null, + throttle: null, + muteAll: false, + mutedInstanceIds: [], + }, + references: [], +}; + +function getAlertStatusSavedObject(attributes: Partial = {}): SavedObject { + return { + ...BaseAlertStatusSavedObject, + attributes: { ...BaseAlertStatusSavedObject.attributes, ...attributes }, + }; +} + +describe('getAlertStatus()', () => { + let alertsClient: AlertsClient; + + beforeEach(() => { + alertsClient = new AlertsClient(alertsClientParams); + }); + + test('runs as expected with some event log data', async () => { + const alertSO = getAlertStatusSavedObject({ mutedInstanceIds: ['instance-muted-no-activity'] }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(alertSO); + + const eventsFactory = new EventsFactory(mockedDateString); + const events = eventsFactory + .addExecute() + .addNewInstance('instance-currently-active') + .addNewInstance('instance-previously-active') + .addActiveInstance('instance-currently-active') + .addActiveInstance('instance-previously-active') + .advanceTime(10000) + .addExecute() + .addResolvedInstance('instance-previously-active') + .addActiveInstance('instance-currently-active') + .getEvents(); + const eventsResult = { + ...AlertStatusFindEventsResult, + total: events.length, + data: events, + }; + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(eventsResult); + + const dateStart = new Date(Date.now() - 60 * 1000).toISOString(); + + const result = await alertsClient.getAlertStatus({ id: '1', dateStart }); + expect(result).toMatchInlineSnapshot(` + Object { + "alertTypeId": "123", + "consumer": "alert-consumer", + "enabled": true, + "errorMessages": Array [], + "id": "1", + "instances": Object { + "instance-currently-active": Object { + "activeStartDate": "2019-02-12T21:01:22.479Z", + "muted": false, + "status": "Active", + }, + "instance-muted-no-activity": Object { + "activeStartDate": undefined, + "muted": true, + "status": "OK", + }, + "instance-previously-active": Object { + "activeStartDate": undefined, + "muted": false, + "status": "OK", + }, + }, + "lastRun": "2019-02-12T21:01:32.479Z", + "muteAll": false, + "name": "alert-name", + "status": "Active", + "statusEndDate": "2019-02-12T21:01:22.479Z", + "statusStartDate": "2019-02-12T21:00:22.479Z", + "tags": Array [ + "tag-1", + "tag-2", + ], + "throttle": null, + } + `); + }); + + // Further tests don't check the result of `getAlertStatus()`, as the result + // is just the result from the `alertStatusFromEventLog()`, which itself + // has a complete set of tests. These tests just make sure the data gets + // sent into `getAlertStatus()` as appropriate. + + test('calls saved objects and event log client with default params', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getAlertStatusSavedObject()); + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(AlertStatusFindEventsResult); + + await alertsClient.getAlertStatus({ id: '1' }); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.findEventsBySavedObject).toHaveBeenCalledTimes(1); + expect(eventLogClient.findEventsBySavedObject.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + "alert", + "1", + Object { + "end": "2019-02-12T21:01:22.479Z", + "page": 1, + "per_page": 10000, + "sort_order": "desc", + "start": "2019-02-12T21:00:22.479Z", + }, + ] + `); + // calculate the expected start/end date for one test + const { start, end } = eventLogClient.findEventsBySavedObject.mock.calls[0][2]!; + expect(end).toBe(mockedDateString); + + const startMillis = Date.parse(start!); + const endMillis = Date.parse(end!); + const expectedDuration = 60 * AlertStatusIntervalSeconds * 1000; + expect(endMillis - startMillis).toBeGreaterThan(expectedDuration - 2); + expect(endMillis - startMillis).toBeLessThan(expectedDuration + 2); + }); + + test('calls event log client with start date', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getAlertStatusSavedObject()); + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(AlertStatusFindEventsResult); + + const dateStart = new Date(Date.now() - 60 * AlertStatusIntervalSeconds * 1000).toISOString(); + await alertsClient.getAlertStatus({ id: '1', dateStart }); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.findEventsBySavedObject).toHaveBeenCalledTimes(1); + const { start, end } = eventLogClient.findEventsBySavedObject.mock.calls[0][2]!; + + expect({ start, end }).toMatchInlineSnapshot(` + Object { + "end": "2019-02-12T21:01:22.479Z", + "start": "2019-02-12T21:00:22.479Z", + } + `); + }); + + test('calls event log client with relative start date', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getAlertStatusSavedObject()); + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(AlertStatusFindEventsResult); + + const dateStart = '2m'; + await alertsClient.getAlertStatus({ id: '1', dateStart }); + + expect(unsecuredSavedObjectsClient.get).toHaveBeenCalledTimes(1); + expect(eventLogClient.findEventsBySavedObject).toHaveBeenCalledTimes(1); + const { start, end } = eventLogClient.findEventsBySavedObject.mock.calls[0][2]!; + + expect({ start, end }).toMatchInlineSnapshot(` + Object { + "end": "2019-02-12T21:01:22.479Z", + "start": "2019-02-12T20:59:22.479Z", + } + `); + }); + + test('invalid start date throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getAlertStatusSavedObject()); + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(AlertStatusFindEventsResult); + + const dateStart = 'ain"t no way this will get parsed as a date'; + expect(alertsClient.getAlertStatus({ id: '1', dateStart })).rejects.toMatchInlineSnapshot( + `[Error: Invalid date for parameter dateStart: "ain"t no way this will get parsed as a date"]` + ); + }); + + test('saved object get throws an error', async () => { + unsecuredSavedObjectsClient.get.mockRejectedValueOnce(new Error('OMG!')); + eventLogClient.findEventsBySavedObject.mockResolvedValueOnce(AlertStatusFindEventsResult); + + expect(alertsClient.getAlertStatus({ id: '1' })).rejects.toMatchInlineSnapshot(`[Error: OMG!]`); + }); + + test('findEvents throws an error', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce(getAlertStatusSavedObject()); + eventLogClient.findEventsBySavedObject.mockRejectedValueOnce(new Error('OMG 2!')); + + // error eaten but logged + await alertsClient.getAlertStatus({ id: '1' }); + }); +}); + describe('find()', () => { const listedTypes = new Set([ { diff --git a/x-pack/plugins/alerts/server/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client.ts index dd66ccc7a0256..80e021fc5cb6e 100644 --- a/x-pack/plugins/alerts/server/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client.ts @@ -24,6 +24,7 @@ import { IntervalSchedule, SanitizedAlert, AlertTaskState, + AlertStatus, } from './types'; import { validateAlertTypeParams } from './lib'; import { @@ -41,6 +42,11 @@ import { WriteOperations, ReadOperations, } from './authorization/alerts_authorization'; +import { IEventLogClient } from '../../../plugins/event_log/server'; +import { parseIsoOrRelativeDate } from './lib/iso_or_relative_date'; +import { alertStatusFromEventLog } from './lib/alert_status_from_event_log'; +import { IEvent } from '../../event_log/server'; +import { parseDuration } from '../common/parse_duration'; export interface RegistryAlertTypeWithAuth extends RegistryAlertType { authorizedConsumers: string[]; @@ -67,6 +73,7 @@ export interface ConstructorOptions { createAPIKey: (name: string) => Promise; invalidateAPIKey: (params: InvalidateAPIKeyParams) => Promise; getActionsClient: () => Promise; + getEventLogClient: () => Promise; } export interface MuteOptions extends IndexType { @@ -132,6 +139,11 @@ interface UpdateOptions { }; } +interface GetAlertStatusParams { + id: string; + dateStart?: string; +} + export class AlertsClient { private readonly logger: Logger; private readonly getUserName: () => Promise; @@ -147,6 +159,7 @@ export class AlertsClient { ) => Promise; private readonly getActionsClient: () => Promise; private readonly actionsAuthorization: ActionsAuthorization; + private readonly getEventLogClient: () => Promise; encryptedSavedObjectsClient: EncryptedSavedObjectsClient; constructor({ @@ -163,6 +176,7 @@ export class AlertsClient { encryptedSavedObjectsClient, getActionsClient, actionsAuthorization, + getEventLogClient, }: ConstructorOptions) { this.logger = logger; this.getUserName = getUserName; @@ -177,6 +191,7 @@ export class AlertsClient { this.encryptedSavedObjectsClient = encryptedSavedObjectsClient; this.getActionsClient = getActionsClient; this.actionsAuthorization = actionsAuthorization; + this.getEventLogClient = getEventLogClient; } public async create({ data, options }: CreateOptions): Promise { @@ -269,6 +284,49 @@ export class AlertsClient { } } + public async getAlertStatus({ id, dateStart }: GetAlertStatusParams): Promise { + this.logger.debug(`getAlertStatus(): getting alert ${id}`); + const alert = await this.get({ id }); + await this.authorization.ensureAuthorized( + alert.alertTypeId, + alert.consumer, + ReadOperations.GetAlertStatus + ); + + // default duration of status is 60 * alert interval + const dateNow = new Date(); + const durationMillis = parseDuration(alert.schedule.interval) * 60; + const defaultDateStart = new Date(dateNow.valueOf() - durationMillis); + const parsedDateStart = parseDate(dateStart, 'dateStart', defaultDateStart); + + const eventLogClient = await this.getEventLogClient(); + + this.logger.debug(`getAlertStatus(): search the event log for alert ${id}`); + let events: IEvent[]; + try { + const queryResults = await eventLogClient.findEventsBySavedObject('alert', id, { + page: 1, + per_page: 10000, + start: parsedDateStart.toISOString(), + end: dateNow.toISOString(), + sort_order: 'desc', + }); + events = queryResults.data; + } catch (err) { + this.logger.debug( + `alertsClient.getAlertStatus(): error searching event log for alert ${id}: ${err.message}` + ); + events = []; + } + + return alertStatusFromEventLog({ + alert, + events, + dateStart: parsedDateStart.toISOString(), + dateEnd: dateNow.toISOString(), + }); + } + public async find({ options: { fields, ...options } = {}, }: { options?: FindOptions } = {}): Promise { @@ -283,7 +341,6 @@ export class AlertsClient { ? `${options.filter} and ${authorizationFilter}` : authorizationFilter; } - const { page, per_page: perPage, @@ -886,3 +943,24 @@ export class AlertsClient { return truncate(`Alerting: ${alertTypeId}/${alertName}`, { length: 256 }); } } + +function parseDate(dateString: string | undefined, propertyName: string, defaultValue: Date): Date { + if (dateString === undefined) { + return defaultValue; + } + + const parsedDate = parseIsoOrRelativeDate(dateString); + if (parsedDate === undefined) { + throw Boom.badRequest( + i18n.translate('xpack.alerts.alertsClient.getAlertStatus.invalidDate', { + defaultMessage: 'Invalid date for parameter {field}: "{dateValue}"', + values: { + field: propertyName, + dateValue: dateString, + }, + }) + ); + } + + return parsedDate; +} diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts index 16b5af499bb90..a5eb371633f1e 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.test.ts @@ -22,6 +22,7 @@ import { actionsMock, actionsAuthorizationMock } from '../../actions/server/mock import { featuresPluginMock } from '../../features/server/mocks'; import { AuditLogger } from '../../security/server'; import { ALERTS_FEATURE_ID } from '../common'; +import { eventLogMock } from '../../event_log/server/mocks'; jest.mock('./alerts_client'); jest.mock('./authorization/alerts_authorization'); @@ -42,6 +43,7 @@ const alertsClientFactoryParams: jest.Mocked = { encryptedSavedObjectsClient: encryptedSavedObjectsMock.createClient(), actions: actionsMock.createStart(), features, + eventLog: eventLogMock.createStart(), }; const fakeRequest = ({ headers: {}, @@ -119,6 +121,7 @@ test('creates an alerts client with proper constructor arguments when security i namespace: 'default', getUserName: expect.any(Function), getActionsClient: expect.any(Function), + getEventLogClient: expect.any(Function), createAPIKey: expect.any(Function), invalidateAPIKey: expect.any(Function), encryptedSavedObjectsClient: alertsClientFactoryParams.encryptedSavedObjectsClient, @@ -164,6 +167,7 @@ test('creates an alerts client with proper constructor arguments', async () => { invalidateAPIKey: expect.any(Function), encryptedSavedObjectsClient: alertsClientFactoryParams.encryptedSavedObjectsClient, getActionsClient: expect.any(Function), + getEventLogClient: expect.any(Function), }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.ts b/x-pack/plugins/alerts/server/alerts_client_factory.ts index 79b0ccaf1f0bc..83202424c9773 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.ts @@ -16,6 +16,7 @@ import { PluginStartContract as FeaturesPluginStart } from '../../features/serve import { AlertsAuthorization } from './authorization/alerts_authorization'; import { AlertsAuthorizationAuditLogger } from './authorization/audit_logger'; import { Space } from '../../spaces/server'; +import { IEventLogClientService } from '../../../plugins/event_log/server'; export interface AlertsClientFactoryOpts { logger: Logger; @@ -28,6 +29,7 @@ export interface AlertsClientFactoryOpts { encryptedSavedObjectsClient: EncryptedSavedObjectsClient; actions: ActionsPluginStartContract; features: FeaturesPluginStart; + eventLog: IEventLogClientService; } export class AlertsClientFactory { @@ -42,6 +44,7 @@ export class AlertsClientFactory { private encryptedSavedObjectsClient!: EncryptedSavedObjectsClient; private actions!: ActionsPluginStartContract; private features!: FeaturesPluginStart; + private eventLog!: IEventLogClientService; public initialize(options: AlertsClientFactoryOpts) { if (this.isInitialized) { @@ -58,10 +61,11 @@ export class AlertsClientFactory { this.encryptedSavedObjectsClient = options.encryptedSavedObjectsClient; this.actions = options.actions; this.features = options.features; + this.eventLog = options.eventLog; } public create(request: KibanaRequest, savedObjects: SavedObjectsServiceStart): AlertsClient { - const { securityPluginSetup, actions, features } = this; + const { securityPluginSetup, actions, eventLog, features } = this; const spaceId = this.getSpaceId(request); const authorization = new AlertsAuthorization({ authorization: securityPluginSetup?.authz, @@ -135,6 +139,9 @@ export class AlertsClientFactory { async getActionsClient() { return actions.getActionsClientWithRequest(request); }, + async getEventLogClient() { + return eventLog.getClient(request); + }, }); } } diff --git a/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts b/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts index 33a9a0bf0396e..b2a214eae9316 100644 --- a/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts +++ b/x-pack/plugins/alerts/server/authorization/alerts_authorization.ts @@ -18,6 +18,7 @@ import { Space } from '../../../spaces/server'; export enum ReadOperations { Get = 'get', GetAlertState = 'getAlertState', + GetAlertStatus = 'getAlertStatus', Find = 'find', } diff --git a/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.test.ts b/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.test.ts new file mode 100644 index 0000000000000..15570d3032f24 --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.test.ts @@ -0,0 +1,464 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SanitizedAlert, AlertStatus } from '../types'; +import { IValidatedEvent } from '../../../event_log/server'; +import { EVENT_LOG_ACTIONS, EVENT_LOG_PROVIDER } from '../plugin'; +import { alertStatusFromEventLog } from './alert_status_from_event_log'; + +const ONE_HOUR_IN_MILLIS = 60 * 60 * 1000; +const dateStart = '2020-06-18T00:00:00.000Z'; +const dateEnd = dateString(dateStart, ONE_HOUR_IN_MILLIS); + +describe('alertStatusFromEventLog', () => { + test('no events and muted ids', async () => { + const alert = createAlert({}); + const events: IValidatedEvent[] = []; + const status: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + expect(status).toMatchInlineSnapshot(` + Object { + "alertTypeId": "123", + "consumer": "alert-consumer", + "enabled": false, + "errorMessages": Array [], + "id": "alert-123", + "instances": Object {}, + "lastRun": undefined, + "muteAll": false, + "name": "alert-name", + "status": "OK", + "statusEndDate": "2020-06-18T01:00:00.000Z", + "statusStartDate": "2020-06-18T00:00:00.000Z", + "tags": Array [], + "throttle": null, + } + `); + }); + + test('different alert properties', async () => { + const alert = createAlert({ + id: 'alert-456', + alertTypeId: '456', + schedule: { interval: '100s' }, + enabled: true, + name: 'alert-name-2', + tags: ['tag-1', 'tag-2'], + consumer: 'alert-consumer-2', + throttle: '1h', + muteAll: true, + }); + const events: IValidatedEvent[] = []; + const status: AlertStatus = alertStatusFromEventLog({ + alert, + events, + dateStart: dateString(dateEnd, ONE_HOUR_IN_MILLIS), + dateEnd: dateString(dateEnd, ONE_HOUR_IN_MILLIS * 2), + }); + + expect(status).toMatchInlineSnapshot(` + Object { + "alertTypeId": "456", + "consumer": "alert-consumer-2", + "enabled": true, + "errorMessages": Array [], + "id": "alert-456", + "instances": Object {}, + "lastRun": undefined, + "muteAll": true, + "name": "alert-name-2", + "status": "OK", + "statusEndDate": "2020-06-18T03:00:00.000Z", + "statusStartDate": "2020-06-18T02:00:00.000Z", + "tags": Array [ + "tag-1", + "tag-2", + ], + "throttle": "1h", + } + `); + }); + + test('two muted instances', async () => { + const alert = createAlert({ + mutedInstanceIds: ['instance-1', 'instance-2'], + }); + const events: IValidatedEvent[] = []; + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": undefined, + "muted": true, + "status": "OK", + }, + "instance-2": Object { + "activeStartDate": undefined, + "muted": true, + "status": "OK", + }, + }, + "lastRun": undefined, + "status": "OK", + } + `); + }); + + test('active alert but no instances', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory.addExecute().advanceTime(10000).addExecute().getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object {}, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "OK", + } + `); + }); + + test('active alert with no instances but has errors', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute('oof!') + .advanceTime(10000) + .addExecute('rut roh!') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, errorMessages, instances } = alertStatus; + expect({ lastRun, status, errorMessages, instances }).toMatchInlineSnapshot(` + Object { + "errorMessages": Array [ + Object { + "date": "2020-06-18T00:00:00.000Z", + "message": "oof!", + }, + Object { + "date": "2020-06-18T00:00:10.000Z", + "message": "rut roh!", + }, + ], + "instances": Object {}, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "Error", + } + `); + }); + + test('alert with currently inactive instance', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addNewInstance('instance-1') + .addActiveInstance('instance-1') + .advanceTime(10000) + .addExecute() + .addResolvedInstance('instance-1') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": undefined, + "muted": false, + "status": "OK", + }, + }, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "OK", + } + `); + }); + + test('alert with currently inactive instance, no new-instance', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addActiveInstance('instance-1') + .advanceTime(10000) + .addExecute() + .addResolvedInstance('instance-1') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": undefined, + "muted": false, + "status": "OK", + }, + }, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "OK", + } + `); + }); + + test('alert with currently active instance', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addNewInstance('instance-1') + .addActiveInstance('instance-1') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": "2020-06-18T00:00:00.000Z", + "muted": false, + "status": "Active", + }, + }, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "Active", + } + `); + }); + + test('alert with currently active instance, no new-instance', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addActiveInstance('instance-1') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": undefined, + "muted": false, + "status": "Active", + }, + }, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "Active", + } + `); + }); + + test('alert with active and inactive muted alerts', async () => { + const alert = createAlert({ mutedInstanceIds: ['instance-1', 'instance-2'] }); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addNewInstance('instance-1') + .addActiveInstance('instance-1') + .addNewInstance('instance-2') + .addActiveInstance('instance-2') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .addResolvedInstance('instance-2') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": "2020-06-18T00:00:00.000Z", + "muted": true, + "status": "Active", + }, + "instance-2": Object { + "activeStartDate": undefined, + "muted": true, + "status": "OK", + }, + }, + "lastRun": "2020-06-18T00:00:10.000Z", + "status": "Active", + } + `); + }); + + test('alert with active and inactive alerts over many executes', async () => { + const alert = createAlert({}); + const eventsFactory = new EventsFactory(); + const events = eventsFactory + .addExecute() + .addNewInstance('instance-1') + .addActiveInstance('instance-1') + .addNewInstance('instance-2') + .addActiveInstance('instance-2') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .addResolvedInstance('instance-2') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .advanceTime(10000) + .addExecute() + .addActiveInstance('instance-1') + .getEvents(); + + const alertStatus: AlertStatus = alertStatusFromEventLog({ alert, events, dateStart, dateEnd }); + + const { lastRun, status, instances } = alertStatus; + expect({ lastRun, status, instances }).toMatchInlineSnapshot(` + Object { + "instances": Object { + "instance-1": Object { + "activeStartDate": "2020-06-18T00:00:00.000Z", + "muted": false, + "status": "Active", + }, + "instance-2": Object { + "activeStartDate": undefined, + "muted": false, + "status": "OK", + }, + }, + "lastRun": "2020-06-18T00:00:30.000Z", + "status": "Active", + } + `); + }); +}); + +function dateString(isoBaseDate: string, offsetMillis = 0): string { + return new Date(Date.parse(isoBaseDate) + offsetMillis).toISOString(); +} + +export class EventsFactory { + private events: IValidatedEvent[] = []; + + constructor(private date: string = dateStart) {} + + getEvents(): IValidatedEvent[] { + // ES normally returns events sorted newest to oldest, so we need to sort + // that way also + const events = this.events.slice(); + events.sort((a, b) => -a!['@timestamp']!.localeCompare(b!['@timestamp']!)); + return events; + } + + getTime(): string { + return this.date; + } + + advanceTime(millis: number): EventsFactory { + this.date = dateString(this.date, millis); + return this; + } + + addExecute(errorMessage?: string): EventsFactory { + let event: IValidatedEvent = { + '@timestamp': this.date, + event: { + provider: EVENT_LOG_PROVIDER, + action: EVENT_LOG_ACTIONS.execute, + }, + }; + + if (errorMessage) { + event = { ...event, error: { message: errorMessage } }; + } + + this.events.push(event); + return this; + } + + addActiveInstance(instanceId: string): EventsFactory { + this.events.push({ + '@timestamp': this.date, + event: { + provider: EVENT_LOG_PROVIDER, + action: EVENT_LOG_ACTIONS.activeInstance, + }, + kibana: { alerting: { instance_id: instanceId } }, + }); + return this; + } + + addNewInstance(instanceId: string): EventsFactory { + this.events.push({ + '@timestamp': this.date, + event: { + provider: EVENT_LOG_PROVIDER, + action: EVENT_LOG_ACTIONS.newInstance, + }, + kibana: { alerting: { instance_id: instanceId } }, + }); + return this; + } + + addResolvedInstance(instanceId: string): EventsFactory { + this.events.push({ + '@timestamp': this.date, + event: { + provider: EVENT_LOG_PROVIDER, + action: EVENT_LOG_ACTIONS.resolvedInstance, + }, + kibana: { alerting: { instance_id: instanceId } }, + }); + return this; + } +} + +function createAlert(overrides: Partial): SanitizedAlert { + return { ...BaseAlert, ...overrides }; +} + +const BaseAlert: SanitizedAlert = { + id: 'alert-123', + alertTypeId: '123', + schedule: { interval: '10s' }, + enabled: false, + name: 'alert-name', + tags: [], + consumer: 'alert-consumer', + throttle: null, + muteAll: false, + mutedInstanceIds: [], + params: { bar: true }, + actions: [], + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + apiKeyOwner: null, +}; diff --git a/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.ts b/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.ts new file mode 100644 index 0000000000000..606bd44c6990c --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/alert_status_from_event_log.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SanitizedAlert, AlertStatus, AlertInstanceStatus } from '../types'; +import { IEvent } from '../../../event_log/server'; +import { EVENT_LOG_ACTIONS, EVENT_LOG_PROVIDER } from '../plugin'; + +export interface AlertStatusFromEventLogParams { + alert: SanitizedAlert; + events: IEvent[]; + dateStart: string; + dateEnd: string; +} + +export function alertStatusFromEventLog(params: AlertStatusFromEventLogParams): AlertStatus { + // initialize the result + const { alert, events, dateStart, dateEnd } = params; + const alertStatus: AlertStatus = { + id: alert.id, + name: alert.name, + tags: alert.tags, + alertTypeId: alert.alertTypeId, + consumer: alert.consumer, + statusStartDate: dateStart, + statusEndDate: dateEnd, + status: 'OK', + muteAll: alert.muteAll, + throttle: alert.throttle, + enabled: alert.enabled, + lastRun: undefined, + errorMessages: [], + instances: {}, + }; + + const instances = new Map(); + + // loop through the events + // should be sorted newest to oldest, we want oldest to newest, so reverse + for (const event of events.reverse()) { + const timeStamp = event?.['@timestamp']; + if (timeStamp === undefined) continue; + + const provider = event?.event?.provider; + if (provider !== EVENT_LOG_PROVIDER) continue; + + const action = event?.event?.action; + if (action === undefined) continue; + + if (action === EVENT_LOG_ACTIONS.execute) { + alertStatus.lastRun = timeStamp; + + const errorMessage = event?.error?.message; + if (errorMessage !== undefined) { + alertStatus.status = 'Error'; + alertStatus.errorMessages.push({ + date: timeStamp, + message: errorMessage, + }); + } else { + alertStatus.status = 'OK'; + } + + continue; + } + + const instanceId = event?.kibana?.alerting?.instance_id; + if (instanceId === undefined) continue; + + const status = getAlertInstanceStatus(instances, instanceId); + switch (action) { + case EVENT_LOG_ACTIONS.newInstance: + status.activeStartDate = timeStamp; + // intentionally no break here + case EVENT_LOG_ACTIONS.activeInstance: + status.status = 'Active'; + break; + case EVENT_LOG_ACTIONS.resolvedInstance: + status.status = 'OK'; + status.activeStartDate = undefined; + } + } + + // set the muted status of instances + for (const instanceId of alert.mutedInstanceIds) { + getAlertInstanceStatus(instances, instanceId).muted = true; + } + + // convert the instances map to object form + const instanceIds = Array.from(instances.keys()).sort(); + for (const instanceId of instanceIds) { + alertStatus.instances[instanceId] = instances.get(instanceId)!; + } + + // set the overall alert status to Active if appropriate + if (alertStatus.status !== 'Error') { + if (Array.from(instances.values()).some((instance) => instance.status === 'Active')) { + alertStatus.status = 'Active'; + } + } + + alertStatus.errorMessages.sort((a, b) => a.date.localeCompare(b.date)); + + return alertStatus; +} + +// return an instance status object, creating and adding to the map if needed +function getAlertInstanceStatus( + instances: Map, + instanceId: string +): AlertInstanceStatus { + if (instances.has(instanceId)) return instances.get(instanceId)!; + + const status: AlertInstanceStatus = { + status: 'OK', + muted: false, + activeStartDate: undefined, + }; + instances.set(instanceId, status); + return status; +} diff --git a/x-pack/plugins/alerts/server/lib/iso_or_relative_date.test.ts b/x-pack/plugins/alerts/server/lib/iso_or_relative_date.test.ts new file mode 100644 index 0000000000000..91272c1cca3b5 --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/iso_or_relative_date.test.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parseIsoOrRelativeDate } from './iso_or_relative_date'; + +describe('parseIsoOrRelativeDate', () => { + test('handles ISO dates', () => { + const date = new Date(); + const parsedDate = parseIsoOrRelativeDate(date.toISOString()); + expect(parsedDate?.valueOf()).toBe(date.valueOf()); + }); + + test('handles relative dates', () => { + const hoursDiff = 1; + const date = new Date(Date.now() - hoursDiff * 60 * 60 * 1000); + const parsedDate = parseIsoOrRelativeDate(`${hoursDiff}h`); + const diff = Math.abs(parsedDate!.valueOf() - date.valueOf()); + expect(diff).toBeLessThan(1000); + }); + + test('returns undefined for invalid date strings', () => { + const parsedDate = parseIsoOrRelativeDate('this shall not pass'); + expect(parsedDate).toBeUndefined(); + }); +}); diff --git a/x-pack/plugins/alerts/server/lib/iso_or_relative_date.ts b/x-pack/plugins/alerts/server/lib/iso_or_relative_date.ts new file mode 100644 index 0000000000000..77c4eefa04439 --- /dev/null +++ b/x-pack/plugins/alerts/server/lib/iso_or_relative_date.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +import { parseDuration } from '../../common/parse_duration'; + +/** + * Parse an ISO date or NNx duration string as a Date + * + * @param dateString an ISO date or NNx "duration" string representing now-duration + * @returns a Date or undefined if the dateString was not valid + */ +export function parseIsoOrRelativeDate(dateString: string): Date | undefined { + const epochMillis = Date.parse(dateString); + if (!isNaN(epochMillis)) return new Date(epochMillis); + + let millis: number; + try { + millis = parseDuration(dateString); + } catch (err) { + return; + } + + return new Date(Date.now() - millis); +} diff --git a/x-pack/plugins/alerts/server/plugin.ts b/x-pack/plugins/alerts/server/plugin.ts index 5d69887bd5bf0..d5843bd531d84 100644 --- a/x-pack/plugins/alerts/server/plugin.ts +++ b/x-pack/plugins/alerts/server/plugin.ts @@ -38,6 +38,7 @@ import { findAlertRoute, getAlertRoute, getAlertStateRoute, + getAlertStatusRoute, listAlertTypesRoute, updateAlertRoute, enableAlertRoute, @@ -57,16 +58,17 @@ import { import { Services } from './types'; import { registerAlertsUsageCollector } from './usage'; import { initializeAlertingTelemetry, scheduleAlertingTelemetry } from './usage/task'; -import { IEventLogger, IEventLogService } from '../../event_log/server'; +import { IEventLogger, IEventLogService, IEventLogClientService } from '../../event_log/server'; import { PluginStartContract as FeaturesPluginStart } from '../../features/server'; import { setupSavedObjects } from './saved_objects'; -const EVENT_LOG_PROVIDER = 'alerting'; +export const EVENT_LOG_PROVIDER = 'alerting'; export const EVENT_LOG_ACTIONS = { execute: 'execute', executeAction: 'execute-action', newInstance: 'new-instance', resolvedInstance: 'resolved-instance', + activeInstance: 'active-instance', }; export interface PluginSetupContract { @@ -92,6 +94,7 @@ export interface AlertingPluginsStart { taskManager: TaskManagerStartContract; encryptedSavedObjects: EncryptedSavedObjectsPluginStart; features: FeaturesPluginStart; + eventLog: IEventLogClientService; } export class AlertingPlugin { @@ -189,6 +192,7 @@ export class AlertingPlugin { findAlertRoute(router, this.licenseState); getAlertRoute(router, this.licenseState); getAlertStateRoute(router, this.licenseState); + getAlertStatusRoute(router, this.licenseState); listAlertTypesRoute(router, this.licenseState); updateAlertRoute(router, this.licenseState); enableAlertRoute(router, this.licenseState); @@ -235,6 +239,7 @@ export class AlertingPlugin { }, actions: plugins.actions, features: plugins.features, + eventLog: plugins.eventLog, }); const getAlertsClientWithRequest = (request: KibanaRequest) => { diff --git a/x-pack/plugins/alerts/server/routes/get_alert_status.test.ts b/x-pack/plugins/alerts/server/routes/get_alert_status.test.ts new file mode 100644 index 0000000000000..1b4cb1941018b --- /dev/null +++ b/x-pack/plugins/alerts/server/routes/get_alert_status.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { getAlertStatusRoute } from './get_alert_status'; +import { httpServiceMock } from 'src/core/server/mocks'; +import { mockLicenseState } from '../lib/license_state.mock'; +import { mockHandlerArguments } from './_mock_handler_arguments'; +import { SavedObjectsErrorHelpers } from 'src/core/server'; +import { alertsClientMock } from '../alerts_client.mock'; +import { AlertStatus } from '../types'; + +const alertsClient = alertsClientMock.create(); +jest.mock('../lib/license_api_access.ts', () => ({ + verifyApiAccess: jest.fn(), +})); + +beforeEach(() => { + jest.resetAllMocks(); +}); + +describe('getAlertStatusRoute', () => { + const dateString = new Date().toISOString(); + const mockedAlertStatus: AlertStatus = { + id: '', + name: '', + tags: [], + alertTypeId: '', + consumer: '', + muteAll: false, + throttle: null, + enabled: false, + statusStartDate: dateString, + statusEndDate: dateString, + status: 'OK', + errorMessages: [], + instances: {}, + }; + + it('gets alert status', async () => { + const licenseState = mockLicenseState(); + const router = httpServiceMock.createRouter(); + + getAlertStatusRoute(router, licenseState); + + const [config, handler] = router.get.mock.calls[0]; + + expect(config.path).toMatchInlineSnapshot(`"/api/alerts/alert/{id}/status"`); + + alertsClient.getAlertStatus.mockResolvedValueOnce(mockedAlertStatus); + + const [context, req, res] = mockHandlerArguments( + { alertsClient }, + { + params: { + id: '1', + }, + query: {}, + }, + ['ok'] + ); + + await handler(context, req, res); + + expect(alertsClient.getAlertStatus).toHaveBeenCalledTimes(1); + expect(alertsClient.getAlertStatus.mock.calls[0]).toMatchInlineSnapshot(` + Array [ + Object { + "dateStart": undefined, + "id": "1", + }, + ] + `); + + expect(res.ok).toHaveBeenCalled(); + }); + + it('returns NOT-FOUND when alert is not found', async () => { + const licenseState = mockLicenseState(); + const router = httpServiceMock.createRouter(); + + getAlertStatusRoute(router, licenseState); + + const [, handler] = router.get.mock.calls[0]; + + alertsClient.getAlertStatus = jest + .fn() + .mockResolvedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError('alert', '1')); + + const [context, req, res] = mockHandlerArguments( + { alertsClient }, + { + params: { + id: '1', + }, + query: {}, + }, + ['notFound'] + ); + + expect(await handler(context, req, res)).toEqual(undefined); + }); +}); diff --git a/x-pack/plugins/alerts/server/routes/get_alert_status.ts b/x-pack/plugins/alerts/server/routes/get_alert_status.ts new file mode 100644 index 0000000000000..eab18c50189f4 --- /dev/null +++ b/x-pack/plugins/alerts/server/routes/get_alert_status.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema, TypeOf } from '@kbn/config-schema'; +import { + IRouter, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, +} from 'kibana/server'; +import { LicenseState } from '../lib/license_state'; +import { verifyApiAccess } from '../lib/license_api_access'; +import { BASE_ALERT_API_PATH } from '../../common'; + +const paramSchema = schema.object({ + id: schema.string(), +}); + +const querySchema = schema.object({ + dateStart: schema.maybe(schema.string()), +}); + +export const getAlertStatusRoute = (router: IRouter, licenseState: LicenseState) => { + router.get( + { + path: `${BASE_ALERT_API_PATH}/alert/{id}/status`, + validate: { + params: paramSchema, + query: querySchema, + }, + }, + router.handleLegacyErrors(async function ( + context: RequestHandlerContext, + req: KibanaRequest, TypeOf, unknown>, + res: KibanaResponseFactory + ): Promise { + verifyApiAccess(licenseState); + if (!context.alerting) { + return res.badRequest({ body: 'RouteHandlerContext is not registered for alerting' }); + } + const alertsClient = context.alerting.getAlertsClient(); + const { id } = req.params; + const { dateStart } = req.query; + const status = await alertsClient.getAlertStatus({ id, dateStart }); + return res.ok({ body: status }); + }) + ); +}; diff --git a/x-pack/plugins/alerts/server/routes/index.ts b/x-pack/plugins/alerts/server/routes/index.ts index f833a29c67bb9..4c6b1eb8e9b58 100644 --- a/x-pack/plugins/alerts/server/routes/index.ts +++ b/x-pack/plugins/alerts/server/routes/index.ts @@ -9,6 +9,7 @@ export { deleteAlertRoute } from './delete'; export { findAlertRoute } from './find'; export { getAlertRoute } from './get'; export { getAlertStateRoute } from './get_alert_state'; +export { getAlertStatusRoute } from './get_alert_status'; export { listAlertTypesRoute } from './list_alert_types'; export { updateAlertRoute } from './update'; export { enableAlertRoute } from './enable'; diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts index 4abe58de5a904..58b1fa4a123e1 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.test.ts @@ -224,7 +224,7 @@ describe('Task Runner', () => { `); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); expect(eventLogger.logEvent).toHaveBeenCalledWith({ event: { action: 'execute', @@ -261,6 +261,25 @@ describe('Task Runner', () => { }, message: "test:1: 'alert-name' created new instance: '1'", }); + expect(eventLogger.logEvent).toHaveBeenCalledWith({ + event: { + action: 'active-instance', + }, + kibana: { + alerting: { + instance_id: '1', + }, + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + }, + ], + }, + message: "test:1: 'alert-name' active instance: '1'", + }); expect(eventLogger.logEvent).toHaveBeenCalledWith({ event: { action: 'execute-action', @@ -345,7 +364,7 @@ describe('Task Runner', () => { `); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` Array [ Array [ @@ -388,6 +407,27 @@ describe('Task Runner', () => { "message": "test:1: 'alert-name' created new instance: '1'", }, ], + Array [ + Object { + "event": Object { + "action": "active-instance", + }, + "kibana": Object { + "alerting": Object { + "instance_id": "1", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: 'alert-name' active instance: '1'", + }, + ], Array [ Object { "event": Object { @@ -465,7 +505,7 @@ describe('Task Runner', () => { `); const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; - expect(eventLogger.logEvent).toHaveBeenCalledTimes(2); + expect(eventLogger.logEvent).toHaveBeenCalledTimes(3); expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` Array [ Array [ @@ -508,6 +548,27 @@ describe('Task Runner', () => { "message": "test:1: 'alert-name' resolved instance: '2'", }, ], + Array [ + Object { + "event": Object { + "action": "active-instance", + }, + "kibana": Object { + "alerting": Object { + "instance_id": "1", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: 'alert-name' active instance: '1'", + }, + ], ] `); }); diff --git a/x-pack/plugins/alerts/server/task_runner/task_runner.ts b/x-pack/plugins/alerts/server/task_runner/task_runner.ts index 04fea58f250a3..4c16d23b485b5 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -355,41 +355,53 @@ interface GenerateNewAndResolvedInstanceEventsParams { } function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInstanceEventsParams) { - const { currentAlertInstanceIds, originalAlertInstanceIds } = params; + const { + eventLogger, + alertId, + namespace, + currentAlertInstanceIds, + originalAlertInstanceIds, + } = params; + const newIds = without(currentAlertInstanceIds, ...originalAlertInstanceIds); const resolvedIds = without(originalAlertInstanceIds, ...currentAlertInstanceIds); + for (const id of resolvedIds) { + const message = `${params.alertLabel} resolved instance: '${id}'`; + logInstanceEvent(id, EVENT_LOG_ACTIONS.resolvedInstance, message); + } + for (const id of newIds) { const message = `${params.alertLabel} created new instance: '${id}'`; logInstanceEvent(id, EVENT_LOG_ACTIONS.newInstance, message); } - for (const id of resolvedIds) { - const message = `${params.alertLabel} resolved instance: '${id}'`; - logInstanceEvent(id, EVENT_LOG_ACTIONS.resolvedInstance, message); + for (const id of currentAlertInstanceIds) { + const message = `${params.alertLabel} active instance: '${id}'`; + logInstanceEvent(id, EVENT_LOG_ACTIONS.activeInstance, message); } - function logInstanceEvent(id: string, action: string, message: string) { + function logInstanceEvent(instanceId: string, action: string, message: string) { const event: IEvent = { event: { action, }, kibana: { alerting: { - instance_id: id, + instance_id: instanceId, }, saved_objects: [ { rel: SAVED_OBJECT_REL_PRIMARY, type: 'alert', - id: params.alertId, - namespace: params.namespace, + id: alertId, + namespace, }, ], }, message, }; - params.eventLogger.logEvent(event); + eventLogger.logEvent(event); } } diff --git a/x-pack/plugins/event_log/server/event_log_start_service.test.ts b/x-pack/plugins/event_log/server/event_log_start_service.test.ts index cbdc168a8ffde..0a5b169e87d4d 100644 --- a/x-pack/plugins/event_log/server/event_log_start_service.test.ts +++ b/x-pack/plugins/event_log/server/event_log_start_service.test.ts @@ -28,6 +28,14 @@ describe('EventLogClientService', () => { eventLogStartService.getClient(request); + const savedObjectGetter = savedObjectProviderRegistry.getProvidersClient(request); + expect(jest.requireMock('./event_log_client').EventLogClient).toHaveBeenCalledWith({ + esContext, + request, + savedObjectGetter, + spacesService: undefined, + }); + expect(savedObjectProviderRegistry.getProvidersClient).toHaveBeenCalledWith(request); }); }); diff --git a/x-pack/plugins/event_log/server/index.ts b/x-pack/plugins/event_log/server/index.ts index 25b1b95831b8a..7169aa6ff9baa 100644 --- a/x-pack/plugins/event_log/server/index.ts +++ b/x-pack/plugins/event_log/server/index.ts @@ -14,7 +14,10 @@ export { IEventLogClientService, IEvent, IValidatedEvent, + IEventLogClient, + QueryEventsBySavedObjectResult, SAVED_OBJECT_REL_PRIMARY, } from './types'; + export const config = { schema: ConfigSchema }; export const plugin = (context: PluginInitializerContext) => new Plugin(context); diff --git a/x-pack/plugins/event_log/server/mocks.ts b/x-pack/plugins/event_log/server/mocks.ts index 2f632a52d2f36..39ec9c42522dc 100644 --- a/x-pack/plugins/event_log/server/mocks.ts +++ b/x-pack/plugins/event_log/server/mocks.ts @@ -7,6 +7,8 @@ import { eventLogServiceMock } from './event_log_service.mock'; import { eventLogStartServiceMock } from './event_log_start_service.mock'; +export { eventLogClientMock } from './event_log_client.mock'; + export { eventLogServiceMock, eventLogStartServiceMock }; export { eventLoggerMock } from './event_logger.mock'; diff --git a/x-pack/plugins/event_log/server/types.ts b/x-pack/plugins/event_log/server/types.ts index cda9579220623..66030ee3910dc 100644 --- a/x-pack/plugins/event_log/server/types.ts +++ b/x-pack/plugins/event_log/server/types.ts @@ -12,6 +12,7 @@ export { IEvent, IValidatedEvent, EventSchema, ECS_VERSION } from '../generated/ import { IEvent } from '../generated/schemas'; import { FindOptionsType } from './event_log_client'; import { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; +export { QueryEventsBySavedObjectResult } from './es/cluster_client_adapter'; import { SavedObjectProvider } from './saved_object_provider_registry'; export const SAVED_OBJECT_REL_PRIMARY = 'primary'; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts index 99d69602db137..636082656f1a4 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.test.ts @@ -74,6 +74,7 @@ describe(`feature_privilege_builder`, () => { Array [ "alerting:1.0.0-zeta1:alert-type/my-feature/get", "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertStatus", "alerting:1.0.0-zeta1:alert-type/my-feature/find", ] `); @@ -110,6 +111,7 @@ describe(`feature_privilege_builder`, () => { Array [ "alerting:1.0.0-zeta1:alert-type/my-feature/get", "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertStatus", "alerting:1.0.0-zeta1:alert-type/my-feature/find", "alerting:1.0.0-zeta1:alert-type/my-feature/create", "alerting:1.0.0-zeta1:alert-type/my-feature/delete", @@ -156,6 +158,7 @@ describe(`feature_privilege_builder`, () => { Array [ "alerting:1.0.0-zeta1:alert-type/my-feature/get", "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:alert-type/my-feature/getAlertStatus", "alerting:1.0.0-zeta1:alert-type/my-feature/find", "alerting:1.0.0-zeta1:alert-type/my-feature/create", "alerting:1.0.0-zeta1:alert-type/my-feature/delete", @@ -169,6 +172,7 @@ describe(`feature_privilege_builder`, () => { "alerting:1.0.0-zeta1:alert-type/my-feature/unmuteInstance", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/get", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/getAlertState", + "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/getAlertStatus", "alerting:1.0.0-zeta1:readonly-alert-type/my-feature/find", ] `); diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts index 42dd7794ba184..540b9e5c1e56e 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/alerting.ts @@ -8,7 +8,7 @@ import { uniq } from 'lodash'; import { Feature, FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; -const readOperations: string[] = ['get', 'getAlertState', 'find']; +const readOperations: string[] = ['get', 'getAlertState', 'getAlertStatus', 'find']; const writeOperations: string[] = [ 'create', 'delete', diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index 35fdc3974a296..7dde344d06fb5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -11,7 +11,7 @@ import { fold } from 'fp-ts/lib/Either'; import { pick } from 'lodash'; import { alertStateSchema, AlertingFrameworkHealth } from '../../../../alerts/common'; import { BASE_ALERT_API_PATH } from '../constants'; -import { Alert, AlertType, AlertWithoutId, AlertTaskState } from '../../types'; +import { Alert, AlertType, AlertWithoutId, AlertTaskState, AlertStatus } from '../../types'; export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { return await http.get(`${BASE_ALERT_API_PATH}/list_alert_types`); @@ -48,6 +48,16 @@ export async function loadAlertState({ }); } +export async function loadAlertStatus({ + http, + alertId, +}: { + http: HttpSetup; + alertId: string; +}): Promise { + return await http.get(`${BASE_ALERT_API_PATH}/alert/${alertId}/status`); +} + export async function loadAlerts({ http, page, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx index dd2ee48b7a620..ff9b518a9f5b1 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.test.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; import { AlertInstances, AlertInstanceListItem, alertInstanceToListItem } from './alert_instances'; -import { Alert, AlertTaskState, RawAlertInstance } from '../../../../types'; +import { Alert, AlertStatus, AlertInstanceStatus } from '../../../../types'; import { EuiBasicTable } from '@elastic/eui'; const fakeNow = new Date('2020-02-09T23:15:41.941Z'); @@ -34,26 +34,37 @@ jest.mock('../../../app_context', () => { describe('alert_instances', () => { it('render a list of alert instances', () => { const alert = mockAlert(); + const alertStatus = mockAlertStatus({ + instances: { + first_instance: { + status: 'OK', + muted: false, + }, + second_instance: { + status: 'OK', + muted: false, + }, + }, + }); - const alertState = mockAlertState(); const instances: AlertInstanceListItem[] = [ alertInstanceToListItem( fakeNow.getTime(), alert, 'first_instance', - alertState.alertInstances!.first_instance + alertStatus.instances.first_instance ), alertInstanceToListItem( fakeNow.getTime(), alert, 'second_instance', - alertState.alertInstances!.second_instance + alertStatus.instances.second_instance ), ]; expect( shallow( - + ) .find(EuiBasicTable) .prop('items') @@ -62,7 +73,7 @@ describe('alert_instances', () => { it('render a hidden field with duration epoch', () => { const alert = mockAlert(); - const alertState = mockAlertState(); + const alertStatus = mockAlertStatus(); expect( shallow( @@ -71,7 +82,7 @@ describe('alert_instances', () => { {...mockAPIs} alert={alert} readOnly={false} - alertState={alertState} + alertStatus={alertStatus} /> ) .find('[name="alertInstancesDurationEpoch"]') @@ -81,17 +92,15 @@ describe('alert_instances', () => { it('render all active alert instances', () => { const alert = mockAlert(); - const instances = { + const instances: Record = { ['us-central']: { - state: {}, - meta: { - lastScheduledActions: { - group: 'warning', - date: fake2MinutesAgo, - }, - }, + status: 'OK', + muted: false, + }, + ['us-east']: { + status: 'OK', + muted: false, }, - ['us-east']: {}, }; expect( shallow( @@ -99,8 +108,8 @@ describe('alert_instances', () => { {...mockAPIs} alert={alert} readOnly={false} - alertState={mockAlertState({ - alertInstances: instances, + alertStatus={mockAlertStatus({ + instances, })} /> ) @@ -116,6 +125,8 @@ describe('alert_instances', () => { const alert = mockAlert({ mutedInstanceIds: ['us-west', 'us-east'], }); + const instanceUsWest: AlertInstanceStatus = { status: 'OK', muted: false }; + const instanceUsEast: AlertInstanceStatus = { status: 'OK', muted: false }; expect( shallow( @@ -123,16 +134,25 @@ describe('alert_instances', () => { {...mockAPIs} alert={alert} readOnly={false} - alertState={mockAlertState({ - alertInstances: {}, + alertStatus={mockAlertStatus({ + instances: { + 'us-west': { + status: 'OK', + muted: false, + }, + 'us-east': { + status: 'OK', + muted: false, + }, + }, })} /> ) .find(EuiBasicTable) .prop('items') ).toEqual([ - alertInstanceToListItem(fakeNow.getTime(), alert, 'us-west'), - alertInstanceToListItem(fakeNow.getTime(), alert, 'us-east'), + alertInstanceToListItem(fakeNow.getTime(), alert, 'us-west', instanceUsWest), + alertInstanceToListItem(fakeNow.getTime(), alert, 'us-east', instanceUsEast), ]); }); }); @@ -141,13 +161,10 @@ describe('alertInstanceToListItem', () => { it('handles active instances', () => { const alert = mockAlert(); const start = fake2MinutesAgo; - const instance: RawAlertInstance = { - meta: { - lastScheduledActions: { - date: start, - group: 'default', - }, - }, + const instance: AlertInstanceStatus = { + status: 'Active', + muted: false, + activeStartDate: fake2MinutesAgo.toISOString(), }; expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ @@ -164,13 +181,10 @@ describe('alertInstanceToListItem', () => { mutedInstanceIds: ['id'], }); const start = fake2MinutesAgo; - const instance: RawAlertInstance = { - meta: { - lastScheduledActions: { - date: start, - group: 'default', - }, - }, + const instance: AlertInstanceStatus = { + status: 'Active', + muted: true, + activeStartDate: fake2MinutesAgo.toISOString(), }; expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ @@ -182,23 +196,11 @@ describe('alertInstanceToListItem', () => { }); }); - it('handles active instances with no meta', () => { + it('handles active instances with start date', () => { const alert = mockAlert(); - const instance: RawAlertInstance = {}; - - expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ - instance: 'id', - status: { label: 'Active', healthColor: 'primary' }, - start: undefined, - duration: 0, - isMuted: false, - }); - }); - - it('handles active instances with no lastScheduledActions', () => { - const alert = mockAlert(); - const instance: RawAlertInstance = { - meta: {}, + const instance: AlertInstanceStatus = { + status: 'Active', + muted: false, }; expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ @@ -214,9 +216,13 @@ describe('alertInstanceToListItem', () => { const alert = mockAlert({ mutedInstanceIds: ['id'], }); - expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id')).toEqual({ + const instance: AlertInstanceStatus = { + status: 'OK', + muted: true, + }; + expect(alertInstanceToListItem(fakeNow.getTime(), alert, 'id', instance)).toEqual({ instance: 'id', - status: { label: 'Inactive', healthColor: 'subdued' }, + status: { label: 'OK', healthColor: 'subdued' }, start: undefined, duration: 0, isMuted: true, @@ -247,23 +253,26 @@ function mockAlert(overloads: Partial = {}): Alert { }; } -function mockAlertState(overloads: Partial = {}): AlertTaskState { - return { - alertTypeState: { - some: 'value', - }, - alertInstances: { - first_instance: { - state: {}, - meta: { - lastScheduledActions: { - group: 'first_group', - date: new Date(), - }, - }, +function mockAlertStatus(overloads: Partial = {}): AlertStatus { + const status: AlertStatus = { + id: 'alert-id', + name: 'alert-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: 'alert-type-id', + consumer: 'alert-consumer', + status: 'OK', + muteAll: false, + throttle: '', + enabled: true, + errorMessages: [], + statusStartDate: fake2MinutesAgo.toISOString(), + statusEndDate: fakeNow.toISOString(), + instances: { + foo: { + status: 'OK', + muted: false, }, - second_instance: {}, }, - ...overloads, }; + return { ...status, ...overloads }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx index e239188659178..77a3b454a1820 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances.tsx @@ -10,8 +10,8 @@ import { i18n } from '@kbn/i18n'; import { EuiBasicTable, EuiHealth, EuiSpacer, EuiSwitch } from '@elastic/eui'; // @ts-ignore import { RIGHT_ALIGNMENT, CENTER_ALIGNMENT } from '@elastic/eui/lib/services'; -import { padStart, difference, chunk } from 'lodash'; -import { Alert, AlertTaskState, RawAlertInstance, Pagination } from '../../../../types'; +import { padStart, chunk } from 'lodash'; +import { Alert, AlertStatus, AlertInstanceStatus, Pagination } from '../../../../types'; import { ComponentOpts as AlertApis, withBulkAlertOperations, @@ -21,7 +21,7 @@ import { DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; type AlertInstancesProps = { alert: Alert; readOnly: boolean; - alertState: AlertTaskState; + alertStatus: AlertStatus; requestRefresh: () => Promise; durationEpoch?: number; } & Pick; @@ -113,7 +113,7 @@ function durationAsString(duration: Duration): string { export function AlertInstances({ alert, readOnly, - alertState: { alertInstances = {} }, + alertStatus, muteAlertInstance, unmuteAlertInstance, requestRefresh, @@ -124,15 +124,10 @@ export function AlertInstances({ size: DEFAULT_SEARCH_PAGE_SIZE, }); - const mergedAlertInstances = [ - ...Object.entries(alertInstances).map(([instanceId, instance]) => - alertInstanceToListItem(durationEpoch, alert, instanceId, instance) - ), - ...difference(alert.mutedInstanceIds, Object.keys(alertInstances)).map((instanceId) => - alertInstanceToListItem(durationEpoch, alert, instanceId) - ), - ]; - const pageOfAlertInstances = getPage(mergedAlertInstances, pagination); + const alertInstances = Object.entries(alertStatus.instances).map(([instanceId, instance]) => + alertInstanceToListItem(durationEpoch, alert, instanceId, instance) + ); + const pageOfAlertInstances = getPage(alertInstances, pagination); const onMuteAction = async (instance: AlertInstanceListItem) => { await (instance.isMuted @@ -155,7 +150,7 @@ export function AlertInstances({ pagination={{ pageIndex: pagination.index, pageSize: pagination.size, - totalItemCount: mergedAlertInstances.length, + totalItemCount: alertInstances.length, }} onChange={({ page: changedPage }: { page: Pagination }) => { setPagination(changedPage); @@ -197,29 +192,27 @@ const ACTIVE_LABEL = i18n.translate( const INACTIVE_LABEL = i18n.translate( 'xpack.triggersActionsUI.sections.alertDetails.alertInstancesList.status.inactive', - { defaultMessage: 'Inactive' } + { defaultMessage: 'OK' } ); -const durationSince = (durationEpoch: number, startTime?: number) => - startTime ? durationEpoch - startTime : 0; - export function alertInstanceToListItem( durationEpoch: number, alert: Alert, instanceId: string, - instance?: RawAlertInstance + instance: AlertInstanceStatus ): AlertInstanceListItem { - const isMuted = alert.mutedInstanceIds.findIndex((muted) => muted === instanceId) >= 0; + const isMuted = !!instance?.muted; + const status = + instance?.status === 'Active' + ? { label: ACTIVE_LABEL, healthColor: 'primary' } + : { label: INACTIVE_LABEL, healthColor: 'subdued' }; + const start = instance?.activeStartDate ? new Date(instance.activeStartDate) : undefined; + const duration = start ? durationEpoch - start.valueOf() : 0; return { instance: instanceId, - status: instance - ? { label: ACTIVE_LABEL, healthColor: 'primary' } - : { label: INACTIVE_LABEL, healthColor: 'subdued' }, - start: instance?.meta?.lastScheduledActions?.date, - duration: durationSince( - durationEpoch, - instance?.meta?.lastScheduledActions?.date?.getTime() ?? 0 - ), + status, + start, + duration, isMuted, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx index 975856beba556..61af8f5478521 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.test.tsx @@ -7,17 +7,20 @@ import * as React from 'react'; import uuid from 'uuid'; import { shallow } from 'enzyme'; import { ToastsApi } from 'kibana/public'; -import { AlertInstancesRoute, getAlertState } from './alert_instances_route'; -import { Alert } from '../../../../types'; +import { AlertInstancesRoute, getAlertStatus } from './alert_instances_route'; +import { Alert, AlertStatus } from '../../../../types'; import { EuiLoadingSpinner } from '@elastic/eui'; +const fakeNow = new Date('2020-02-09T23:15:41.941Z'); +const fake2MinutesAgo = new Date('2020-02-09T23:13:41.941Z'); + jest.mock('../../../app_context', () => { const toastNotifications = jest.fn(); return { useAppDependencies: jest.fn(() => ({ toastNotifications })), }; }); -describe('alert_state_route', () => { +describe('alert_status_route', () => { it('render a loader while fetching data', () => { const alert = mockAlert(); @@ -34,25 +37,25 @@ describe('getAlertState useEffect handler', () => { jest.clearAllMocks(); }); - it('fetches alert state', async () => { + it('fetches alert status', async () => { const alert = mockAlert(); - const alertState = mockAlertState(); - const { loadAlertState } = mockApis(); - const { setAlertState } = mockStateSetter(); + const alertStatus = mockAlertStatus(); + const { loadAlertStatus } = mockApis(); + const { setAlertStatus } = mockStateSetter(); - loadAlertState.mockImplementationOnce(async () => alertState); + loadAlertStatus.mockImplementationOnce(async () => alertStatus); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + await getAlertStatus(alert.id, loadAlertStatus, setAlertStatus, toastNotifications); - expect(loadAlertState).toHaveBeenCalledWith(alert.id); - expect(setAlertState).toHaveBeenCalledWith(alertState); + expect(loadAlertStatus).toHaveBeenCalledWith(alert.id); + expect(setAlertStatus).toHaveBeenCalledWith(alertStatus); }); - it('displays an error if the alert state isnt found', async () => { + it('displays an error if the alert status isnt found', async () => { const actionType = { id: '.server-log', name: 'Server log', @@ -69,34 +72,34 @@ describe('getAlertState useEffect handler', () => { ], }); - const { loadAlertState } = mockApis(); - const { setAlertState } = mockStateSetter(); + const { loadAlertStatus } = mockApis(); + const { setAlertStatus } = mockStateSetter(); - loadAlertState.mockImplementation(async () => { + loadAlertStatus.mockImplementation(async () => { throw new Error('OMG'); }); const toastNotifications = ({ addDanger: jest.fn(), } as unknown) as ToastsApi; - await getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + await getAlertStatus(alert.id, loadAlertStatus, setAlertStatus, toastNotifications); expect(toastNotifications.addDanger).toHaveBeenCalledTimes(1); expect(toastNotifications.addDanger).toHaveBeenCalledWith({ - title: 'Unable to load alert state: OMG', + title: 'Unable to load alert status: OMG', }); }); }); function mockApis() { return { - loadAlertState: jest.fn(), + loadAlertStatus: jest.fn(), requestRefresh: jest.fn(), }; } function mockStateSetter() { return { - setAlertState: jest.fn(), + setAlertStatus: jest.fn(), }; } @@ -123,22 +126,26 @@ function mockAlert(overloads: Partial = {}): Alert { }; } -function mockAlertState(overloads: Partial = {}): any { - return { - alertTypeState: { - some: 'value', - }, - alertInstances: { - first_instance: { - state: {}, - meta: { - lastScheduledActions: { - group: 'first_group', - date: new Date(), - }, - }, +function mockAlertStatus(overloads: Partial = {}): any { + const status: AlertStatus = { + id: 'alert-id', + name: 'alert-name', + tags: ['tag-1', 'tag-2'], + alertTypeId: 'alert-type-id', + consumer: 'alert-consumer', + status: 'OK', + muteAll: false, + throttle: null, + enabled: true, + errorMessages: [], + statusStartDate: fake2MinutesAgo.toISOString(), + statusEndDate: fakeNow.toISOString(), + instances: { + foo: { + status: 'OK', + muted: false, }, - second_instance: {}, }, }; + return status; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx index d8a7d18eb87a9..3afec45bcad64 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_instances_route.tsx @@ -8,7 +8,7 @@ import { i18n } from '@kbn/i18n'; import { ToastsApi } from 'kibana/public'; import React, { useState, useEffect } from 'react'; import { EuiLoadingSpinner } from '@elastic/eui'; -import { Alert, AlertTaskState } from '../../../../types'; +import { Alert, AlertStatus } from '../../../../types'; import { useAppDependencies } from '../../../app_context'; import { ComponentOpts as AlertApis, @@ -16,33 +16,33 @@ import { } from '../../common/components/with_bulk_alert_api_operations'; import { AlertInstancesWithApi as AlertInstances } from './alert_instances'; -type WithAlertStateProps = { +type WithAlertStatusProps = { alert: Alert; readOnly: boolean; requestRefresh: () => Promise; -} & Pick; +} & Pick; -export const AlertInstancesRoute: React.FunctionComponent = ({ +export const AlertInstancesRoute: React.FunctionComponent = ({ alert, readOnly, requestRefresh, - loadAlertState, + loadAlertStatus: loadAlertStatus, }) => { const { toastNotifications } = useAppDependencies(); - const [alertState, setAlertState] = useState(null); + const [alertStatus, setAlertStatus] = useState(null); useEffect(() => { - getAlertState(alert.id, loadAlertState, setAlertState, toastNotifications); + getAlertStatus(alert.id, loadAlertStatus, setAlertStatus, toastNotifications); // eslint-disable-next-line react-hooks/exhaustive-deps }, [alert]); - return alertState ? ( + return alertStatus ? ( ) : (
= ); }; -export async function getAlertState( +export async function getAlertStatus( alertId: string, - loadAlertState: AlertApis['loadAlertState'], - setAlertState: React.Dispatch>, + loadAlertStatus: AlertApis['loadAlertStatus'], + setAlertStatus: React.Dispatch>, toastNotifications: Pick ) { try { - const loadedState = await loadAlertState(alertId); - setAlertState(loadedState); + const loadedStatus = await loadAlertStatus(alertId); + setAlertStatus(loadedStatus); } catch (e) { toastNotifications.addDanger({ title: i18n.translate( 'xpack.triggersActionsUI.sections.alertDetails.unableToLoadAlertStateMessage', { - defaultMessage: 'Unable to load alert state: {message}', + defaultMessage: 'Unable to load alert status: {message}', values: { message: e.message, }, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx index 0c6f71120cc2e..fd8b35a96bdf0 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/common/components/with_bulk_alert_api_operations.tsx @@ -6,7 +6,13 @@ import React from 'react'; -import { Alert, AlertType, AlertTaskState, AlertingFrameworkHealth } from '../../../../types'; +import { + Alert, + AlertType, + AlertTaskState, + AlertStatus, + AlertingFrameworkHealth, +} from '../../../../types'; import { useAppDependencies } from '../../../app_context'; import { deleteAlerts, @@ -22,6 +28,7 @@ import { unmuteAlertInstance, loadAlert, loadAlertState, + loadAlertStatus, loadAlertTypes, health, } from '../../../lib/alert_api'; @@ -51,6 +58,7 @@ export interface ComponentOpts { }>; loadAlert: (id: Alert['id']) => Promise; loadAlertState: (id: Alert['id']) => Promise; + loadAlertStatus: (id: Alert['id']) => Promise; loadAlertTypes: () => Promise; getHealth: () => Promise; } @@ -119,6 +127,7 @@ export function withBulkAlertOperations( deleteAlert={async (alert: Alert) => deleteAlerts({ http, ids: [alert.id] })} loadAlert={async (alertId: Alert['id']) => loadAlert({ http, alertId })} loadAlertState={async (alertId: Alert['id']) => loadAlertState({ http, alertId })} + loadAlertStatus={async (alertId: Alert['id']) => loadAlertStatus({ http, alertId })} loadAlertTypes={async () => loadAlertTypes({ http })} getHealth={async () => health({ http })} /> diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index a42a9f56a751f..0c0d99eed4e7b 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -12,10 +12,20 @@ import { SanitizedAlert as Alert, AlertAction, AlertTaskState, + AlertStatus, + AlertInstanceStatus, RawAlertInstance, AlertingFrameworkHealth, } from '../../alerts/common'; -export { Alert, AlertAction, AlertTaskState, RawAlertInstance, AlertingFrameworkHealth }; +export { + Alert, + AlertAction, + AlertTaskState, + AlertStatus, + AlertInstanceStatus, + RawAlertInstance, + AlertingFrameworkHealth, +}; export { ActionType }; export type ActionTypeIndex = Record; diff --git a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts index 269a9d3a504a2..40b2c33a702aa 100644 --- a/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts +++ b/x-pack/test/alerting_api_integration/common/fixtures/plugins/alerts/server/alert_types.ts @@ -310,23 +310,31 @@ export function defineAlertTypes( defaultActionGroupId: 'default', async executor(alertExecutorOptions: AlertExecutorOptions) { const { services, state, params } = alertExecutorOptions; - const pattern = params.pattern; - if (!Array.isArray(pattern)) throw new Error('pattern is not an array'); - if (pattern.length === 0) throw new Error('pattern is empty'); + const pattern = params.pattern as Record; + if (typeof pattern !== 'object') throw new Error('pattern is not an object'); + let maxPatternLength = 0; + for (const [instanceId, instancePattern] of Object.entries(pattern)) { + if (!Array.isArray(instancePattern)) { + throw new Error(`pattern for instance ${instanceId} is not an array`); + } + maxPatternLength = Math.max(maxPatternLength, instancePattern.length); + } // get the pattern index, return if past it const patternIndex = state.patternIndex ?? 0; - if (patternIndex > pattern.length) { + if (patternIndex >= maxPatternLength) { return { patternIndex }; } // fire if pattern says to - if (pattern[patternIndex]) { - services.alertInstanceFactory('instance').scheduleActions('default'); + for (const [instanceId, instancePattern] of Object.entries(pattern)) { + if (instancePattern[patternIndex]) { + services.alertInstanceFactory(instanceId).scheduleActions('default'); + } } return { - patternIndex: (patternIndex + 1) % pattern.length, + patternIndex: patternIndex + 1, }; }, }; diff --git a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts index 99f51ff244546..aebcd854514b2 100644 --- a/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts +++ b/x-pack/test/alerting_api_integration/common/lib/get_event_log.ts @@ -25,7 +25,7 @@ export async function getEventLog(params: GetEventLogParams): Promise { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + for (const scenario of UserAtSpaceScenarios) { + const { user, space } = scenario; + describe(scenario.id, () => { + it('should handle getAlertStatus alert request appropriately', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/status`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage('get', 'test.noop', 'alertsFixture'), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + const { id, statusStartDate, statusEndDate } = response.body; + expect(id).to.equal(createdAlert.id); + expect(Date.parse(statusStartDate)).to.be.lessThan(Date.parse(statusEndDate)); + + const stableBody = omit(response.body, [ + 'id', + 'statusStartDate', + 'statusEndDate', + 'lastRun', + ]); + expect(stableBody).to.eql({ + name: 'abc', + tags: ['foo'], + alertTypeId: 'test.noop', + consumer: 'alertsFixture', + status: 'OK', + muteAll: false, + throttle: '1m', + enabled: true, + errorMessages: [], + instances: {}, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it('should handle getAlertStatus alert request appropriately when unauthorized', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.unrestricted-noop', + consumer: 'alertsFixture', + }) + ) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/${createdAlert.id}/status`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getConsumerUnauthorizedErrorMessage( + 'get', + 'test.unrestricted-noop', + 'alertsFixture' + ), + statusCode: 403, + }); + break; + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + expect(response.statusCode).to.eql(403); + expect(response.body).to.eql({ + error: 'Forbidden', + message: getProducerUnauthorizedErrorMessage( + 'get', + 'test.unrestricted-noop', + 'alertsRestrictedFixture' + ), + statusCode: 403, + }); + break; + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(200); + expect(response.body).to.key('id', 'instances', 'errorMessages'); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`shouldn't getAlertStatus for an alert from another space`, async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(space.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(space.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertestWithoutAuth + .get(`${getUrlPrefix('other')}/api/alerts/alert/${createdAlert.id}/status`) + .auth(user.username, user.password); + + expect(response.statusCode).to.eql(404); + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + case 'global_read at space1': + case 'superuser at space1': + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: `Saved object [alert/${createdAlert.id}] not found`, + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + + it(`should handle getAlertStatus request appropriately when alert doesn't exist`, async () => { + const response = await supertestWithoutAuth + .get(`${getUrlPrefix(space.id)}/api/alerts/alert/1/status`) + .auth(user.username, user.password); + + switch (scenario.id) { + case 'no_kibana_privileges at space1': + case 'space_1_all at space2': + case 'global_read at space1': + case 'superuser at space1': + case 'space_1_all at space1': + case 'space_1_all_alerts_none_actions at space1': + case 'space_1_all_with_restricted_fixture at space1': + expect(response.statusCode).to.eql(404); + expect(response.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }); + break; + default: + throw new Error(`Scenario untested: ${JSON.stringify(scenario)}`); + } + }); + }); + } + }); +} diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts index 4cd5f0805121c..45fa075a65978 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/index.ts @@ -16,6 +16,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./enable')); loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./get_alert_state')); + loadTestFile(require.resolve('./get_alert_status')); loadTestFile(require.resolve('./list_alert_types')); loadTestFile(require.resolve('./mute_all')); loadTestFile(require.resolve('./mute_instance')); diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts index 79d25d8d10436..a5dff437283ae 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/event_log.ts @@ -35,7 +35,9 @@ export default function eventLogTests({ getService }: FtrProviderContext) { .expect(200); // pattern of when the alert should fire - const pattern = [false, true, true]; + const pattern = { + instance: [false, true, true], + }; const response = await supertest .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) @@ -70,7 +72,13 @@ export default function eventLogTests({ getService }: FtrProviderContext) { type: 'alert', id: alertId, provider: 'alerting', - actions: ['execute', 'execute-action', 'new-instance', 'resolved-instance'], + actions: [ + 'execute', + 'execute-action', + 'new-instance', + 'active-instance', + 'resolved-instance', + ], }); }); @@ -120,24 +128,27 @@ export default function eventLogTests({ getService }: FtrProviderContext) { }); break; case 'new-instance': - validateEvent(event, { - spaceId: Spaces.space1.id, - savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], - message: `test.patternFiring:${alertId}: 'abc' created new instance: 'instance'`, - }); + validateInstanceEvent(event, `created new instance: 'instance'`); break; case 'resolved-instance': - validateEvent(event, { - spaceId: Spaces.space1.id, - savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], - message: `test.patternFiring:${alertId}: 'abc' resolved instance: 'instance'`, - }); + validateInstanceEvent(event, `resolved instance: 'instance'`); + break; + case 'active-instance': + validateInstanceEvent(event, `active instance: 'instance'`); break; // this will get triggered as we add new event actions default: throw new Error(`unexpected event action "${event?.event?.action}"`); } } + + function validateInstanceEvent(event: IValidatedEvent, subMessage: string) { + validateEvent(event, { + spaceId: Spaces.space1.id, + savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], + message: `test.patternFiring:${alertId}: 'abc' ${subMessage}`, + }); + } }); it('should generate events for execution errors', async () => { diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_status.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_status.ts new file mode 100644 index 0000000000000..341313ce55c60 --- /dev/null +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/get_alert_status.ts @@ -0,0 +1,261 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { omit } from 'lodash'; + +import { Spaces } from '../../scenarios'; +import { + getUrlPrefix, + ObjectRemover, + getTestAlertData, + AlertUtils, + getEventLog, +} from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function createGetAlertStatusTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const retry = getService('retry'); + const alertUtils = new AlertUtils({ space: Spaces.space1, supertestWithoutAuth }); + + describe('getAlertStatus', () => { + const objectRemover = new ObjectRemover(supertest); + + afterEach(() => objectRemover.removeAll()); + + it(`handles non-existant alert`, async () => { + await supertest + .get(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/1/status`) + .expect(404, { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [alert/1] not found', + }); + }); + + it('handles no-op alert', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await waitForEvents(createdAlert.id, ['execute']); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}/status` + ); + + expect(response.status).to.eql(200); + + const { statusStartDate, statusEndDate } = response.body; + expect(Date.parse(statusStartDate)).to.be.lessThan(Date.parse(statusEndDate)); + + const stableBody = omit(response.body, ['statusStartDate', 'statusEndDate', 'lastRun']); + expect(stableBody).to.eql({ + id: createdAlert.id, + name: 'abc', + tags: ['foo'], + alertTypeId: 'test.noop', + consumer: 'alertsFixture', + status: 'OK', + muteAll: false, + throttle: '1m', + enabled: true, + errorMessages: [], + instances: {}, + }); + }); + + it('handles no-op alert without waiting for execution event', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}/status` + ); + + expect(response.status).to.eql(200); + + const { statusStartDate, statusEndDate } = response.body; + expect(Date.parse(statusStartDate)).to.be.lessThan(Date.parse(statusEndDate)); + + const stableBody = omit(response.body, ['statusStartDate', 'statusEndDate', 'lastRun']); + expect(stableBody).to.eql({ + id: createdAlert.id, + name: 'abc', + tags: ['foo'], + alertTypeId: 'test.noop', + consumer: 'alertsFixture', + status: 'OK', + muteAll: false, + throttle: '1m', + enabled: true, + errorMessages: [], + instances: {}, + }); + }); + + it('handles dateStart parameter', async () => { + const dateStart = '2020-08-08T08:08:08.008Z'; + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await waitForEvents(createdAlert.id, ['execute']); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${ + createdAlert.id + }/status?dateStart=${dateStart}` + ); + expect(response.status).to.eql(200); + const { statusStartDate, statusEndDate } = response.body; + expect(Date.parse(statusStartDate)).to.be.lessThan(Date.parse(statusEndDate)); + expect(statusStartDate).to.be(dateStart); + }); + + it('handles invalid dateStart parameter', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await waitForEvents(createdAlert.id, ['execute']); + const dateStart = 'X0X0-08-08T08:08:08.008Z'; + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${ + createdAlert.id + }/status?dateStart=${dateStart}` + ); + expect(response.status).to.eql(400); + expect(response.body).to.eql({ + statusCode: 400, + error: 'Bad Request', + message: 'Invalid date for parameter dateStart: "X0X0-08-08T08:08:08.008Z"', + }); + }); + + it('handles muted instances', async () => { + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData()) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await alertUtils.muteInstance(createdAlert.id, '1'); + await waitForEvents(createdAlert.id, ['execute']); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}/status` + ); + + expect(response.status).to.eql(200); + expect(response.body.instances).to.eql({ + '1': { + status: 'OK', + muted: true, + }, + }); + }); + + it('handles alert errors', async () => { + const dateNow = Date.now(); + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send(getTestAlertData({ alertTypeId: 'test.throw' })) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await waitForEvents(createdAlert.id, ['execute']); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}/status` + ); + const { errorMessages } = response.body; + expect(errorMessages.length).to.be.greaterThan(0); + const errorMessage = errorMessages[0]; + expect(Date.parse(errorMessage.date)).to.be.greaterThan(dateNow); + expect(errorMessage.message).to.be('this alert is intended to fail'); + }); + + it('handles multi-instance status', async () => { + // pattern of when the alert should fire + const pattern = { + instanceA: [true, true, true, true], + instanceB: [true, true, false, false], + instanceC: [true, true, true, true], + }; + + const { body: createdAlert } = await supertest + .post(`${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.patternFiring', + params: { pattern }, + schedule: { interval: '1s' }, + }) + ) + .expect(200); + objectRemover.add(Spaces.space1.id, createdAlert.id, 'alert', 'alerts'); + + await alertUtils.muteInstance(createdAlert.id, 'instanceC'); + await alertUtils.muteInstance(createdAlert.id, 'instanceD'); + await waitForEvents(createdAlert.id, ['new-instance', 'resolved-instance']); + const response = await supertest.get( + `${getUrlPrefix(Spaces.space1.id)}/api/alerts/alert/${createdAlert.id}/status` + ); + + const actualInstances = response.body.instances; + const expectedInstances = { + instanceA: { + status: 'Active', + muted: false, + activeStartDate: actualInstances.instanceA.activeStartDate, + }, + instanceB: { + status: 'OK', + muted: false, + }, + instanceC: { + status: 'Active', + muted: true, + activeStartDate: actualInstances.instanceC.activeStartDate, + }, + instanceD: { + status: 'OK', + muted: true, + }, + }; + expect(actualInstances).to.eql(expectedInstances); + }); + }); + + async function waitForEvents(id: string, actions: string[]) { + await retry.try(async () => { + return await getEventLog({ + getService, + spaceId: Spaces.space1.id, + type: 'alert', + id, + provider: 'alerting', + actions, + }); + }); + } +} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts index a23f0fa835313..b927b563eb54a 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/index.ts @@ -16,6 +16,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./get_alert_state')); + loadTestFile(require.resolve('./get_alert_status')); loadTestFile(require.resolve('./list_alert_types')); loadTestFile(require.resolve('./event_log')); loadTestFile(require.resolve('./mute_all')); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index d86d272c1da8c..1579d041c9f58 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -361,7 +361,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // await first run to complete so we have an initial state await retry.try(async () => { - const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + const { instances: alertInstances } = await alerting.alerts.getAlertStatus(alert.id); expect(Object.keys(alertInstances).length).to.eql(instances.length); }); }); @@ -373,15 +373,11 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Verify content await testSubjects.existOrFail('alertInstancesList'); - const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + const status = await alerting.alerts.getAlertStatus(alert.id); const dateOnAllInstancesFromApiResponse = mapValues( - alertInstances, - ({ - meta: { - lastScheduledActions: { date }, - }, - }) => date + status.instances, + (instance) => instance.activeStartDate ); log.debug( @@ -471,7 +467,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { ).to.eql([ { instance: 'eu-east', - status: 'Inactive', + status: 'OK', start: '', duration: '', }, @@ -574,7 +570,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // await first run to complete so we have an initial state await retry.try(async () => { - const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + const { instances: alertInstances } = await alerting.alerts.getAlertStatus(alert.id); expect(Object.keys(alertInstances).length).to.eql(instances.length); }); @@ -595,7 +591,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Verify content await testSubjects.existOrFail('alertInstancesList'); - const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + const { instances: alertInstances } = await alerting.alerts.getAlertStatus(alert.id); const items = await pageObjects.alertDetailsUI.getAlertInstancesList(); expect(items.length).to.eql(PAGE_SIZE); @@ -608,7 +604,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { // Verify content await testSubjects.existOrFail('alertInstancesList'); - const { alertInstances } = await alerting.alerts.getAlertState(alert.id); + const { instances: alertInstances } = await alerting.alerts.getAlertStatus(alert.id); await pageObjects.alertDetailsUI.clickPaginationNextPage(); diff --git a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts index 23a4529139c53..c6fbdecf77f16 100644 --- a/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts +++ b/x-pack/test/functional_with_es_ssl/services/alerting/alerts.ts @@ -8,6 +8,21 @@ import axios, { AxiosInstance } from 'axios'; import util from 'util'; import { ToolingLog } from '@kbn/dev-utils'; +export interface AlertStatus { + status: string; + muted: boolean; + enabled: boolean; + lastRun?: string; + errorMessage?: string; + instances: Record; +} + +export interface AlertInstanceStatus { + status: string; + muted: boolean; + activeStartDate?: string; +} + export class Alerts { private log: ToolingLog; private axios: AxiosInstance; @@ -141,10 +156,10 @@ export class Alerts { this.log.debug(`deleted alert ${alert.id}`); } - public async getAlertState(id: string) { + public async getAlertStatus(id: string): Promise { this.log.debug(`getting alert ${id} state`); - const { data } = await this.axios.get(`/api/alerts/alert/${id}/state`); + const { data } = await this.axios.get(`/api/alerts/alert/${id}/status`); return data; } From f6f59ec261d55e77d48b766cc0d3d64033b522d7 Mon Sep 17 00:00:00 2001 From: Alexey Antonov Date: Fri, 14 Aug 2020 15:46:55 +0300 Subject: [PATCH 12/51] Drilldowns for TSVB / Vega / Timelion (#74848) * Drilldowns for TSVB / Vega Closes: #60611 * fix PR comment * fix PR comments * add support for Timelion * rename vis.API.events.brush -> vis.API.events.applyFilter --- .../public/components/chart.tsx | 2 + .../public/components/panel.tsx | 22 ++++++++--- .../public/components/timelion_vis.tsx | 1 + .../public/timelion_vis_type.tsx | 5 +++ .../application/components/vis_editor.js | 2 +- ...r.test.js => create_brush_handler.test.ts} | 37 ++++++++++--------- ...ush_handler.js => create_brush_handler.ts} | 25 +++++++++---- .../public/metrics_type.ts | 4 ++ src/plugins/vis_type_vega/public/vega_type.ts | 4 ++ .../public/vega_view/vega_base_view.js | 21 ++++++++++- .../public/vega_visualization.js | 1 + .../public/vega_visualization.test.js | 5 +++ .../public/embeddable/events.ts | 8 +++- .../public/embeddable/visualize_embeddable.ts | 21 ++++++++--- .../visualizations/public/expressions/vis.ts | 5 +++ src/plugins/visualizations/public/index.ts | 1 + 16 files changed, 124 insertions(+), 40 deletions(-) rename src/plugins/vis_type_timeseries/public/application/lib/{create_brush_handler.test.js => create_brush_handler.test.ts} (58%) rename src/plugins/vis_type_timeseries/public/application/lib/{create_brush_handler.js => create_brush_handler.ts} (68%) diff --git a/src/plugins/vis_type_timelion/public/components/chart.tsx b/src/plugins/vis_type_timelion/public/components/chart.tsx index a8b03bdbc8b7e..15a376d4e9638 100644 --- a/src/plugins/vis_type_timelion/public/components/chart.tsx +++ b/src/plugins/vis_type_timelion/public/components/chart.tsx @@ -21,8 +21,10 @@ import React from 'react'; import { Sheet } from '../helpers/timelion_request_handler'; import { Panel } from './panel'; +import { ExprVisAPIEvents } from '../../../visualizations/public'; interface ChartComponentProp { + applyFilter: ExprVisAPIEvents['applyFilter']; interval: string; renderComplete(): void; seriesList: Sheet; diff --git a/src/plugins/vis_type_timelion/public/components/panel.tsx b/src/plugins/vis_type_timelion/public/components/panel.tsx index f4f1cd84613be..9c30a6b75d6db 100644 --- a/src/plugins/vis_type_timelion/public/components/panel.tsx +++ b/src/plugins/vis_type_timelion/public/components/panel.tsx @@ -33,10 +33,12 @@ import { colors, Axis, } from '../helpers/panel_utils'; + import { Series, Sheet } from '../helpers/timelion_request_handler'; import { tickFormatters } from '../helpers/tick_formatters'; import { generateTicksProvider } from '../helpers/tick_generator'; import { TimelionVisDependencies } from '../plugin'; +import { ExprVisAPIEvents } from '../../../visualizations/public'; interface CrosshairPlot extends jquery.flot.plot { setCrosshair: (pos: Position) => void; @@ -44,6 +46,7 @@ interface CrosshairPlot extends jquery.flot.plot { } interface PanelProps { + applyFilter: ExprVisAPIEvents['applyFilter']; interval: string; seriesList: Sheet; renderComplete(): void; @@ -72,7 +75,7 @@ const DEBOUNCE_DELAY = 50; // ensure legend is the same height with or without a caption so legend items do not move around const emptyCaption = '
'; -function Panel({ interval, seriesList, renderComplete }: PanelProps) { +function Panel({ interval, seriesList, renderComplete, applyFilter }: PanelProps) { const kibana = useKibana(); const [chart, setChart] = useState(() => cloneDeep(seriesList.list)); const [canvasElem, setCanvasElem] = useState(); @@ -346,12 +349,21 @@ function Panel({ interval, seriesList, renderComplete }: PanelProps) { const plotSelectedHandler = useCallback( (event: JQuery.TriggeredEvent, ranges: Ranges) => { - kibana.services.timefilter.setTime({ - from: moment(ranges.xaxis.from), - to: moment(ranges.xaxis.to), + applyFilter({ + timeFieldName: '*', + filters: [ + { + range: { + '*': { + gte: ranges.xaxis.from, + lte: ranges.xaxis.to, + }, + }, + }, + ], }); }, - [kibana.services.timefilter] + [applyFilter] ); useEffect(() => { diff --git a/src/plugins/vis_type_timelion/public/components/timelion_vis.tsx b/src/plugins/vis_type_timelion/public/components/timelion_vis.tsx index 4bb07fe74ee82..aa594c749b600 100644 --- a/src/plugins/vis_type_timelion/public/components/timelion_vis.tsx +++ b/src/plugins/vis_type_timelion/public/components/timelion_vis.tsx @@ -38,6 +38,7 @@ function TimelionVisComponent(props: TimelionVisComponentProp) { return (
{ + return [VIS_EVENT_TO_TRIGGER.applyFilter]; + }, options: { showIndexSelection: false, showQueryBar: false, diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js index 300e70f3ae0c0..50585869862ee 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js @@ -50,7 +50,7 @@ export class VisEditor extends Component { visFields: props.visFields, extractedIndexPatterns: [''], }; - this.onBrush = createBrushHandler(getDataStart().query.timefilter.timefilter); + this.onBrush = createBrushHandler((data) => props.vis.API.events.applyFilter(data)); this.visDataSubject = new Rx.BehaviorSubject(this.props.visData); this.visData$ = this.visDataSubject.asObservable().pipe(share()); diff --git a/src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.test.js b/src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.test.ts similarity index 58% rename from src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.test.js rename to src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.test.ts index 6ae01a384e7ca..a9568b5be9d3f 100644 --- a/src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.test.js +++ b/src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.test.ts @@ -18,28 +18,31 @@ */ import { createBrushHandler } from './create_brush_handler'; -import moment from 'moment'; +import { ExprVisAPIEvents } from '../../../../visualizations/public'; describe('brushHandler', () => { - let mockTimefilter; - let onBrush; + let onBrush: ReturnType; + let applyFilter: ExprVisAPIEvents['applyFilter']; beforeEach(() => { - mockTimefilter = { - time: {}, - setTime: function (time) { - this.time = time; - }, - }; - onBrush = createBrushHandler(mockTimefilter); + applyFilter = jest.fn(); + + onBrush = createBrushHandler(applyFilter); }); - it('returns brushHandler() that updates timefilter', () => { - const from = '2017-01-01T00:00:00Z'; - const to = '2017-01-01T00:10:00Z'; - onBrush(from, to); - expect(mockTimefilter.time.from).toEqual(moment(from).toISOString()); - expect(mockTimefilter.time.to).toEqual(moment(to).toISOString()); - expect(mockTimefilter.time.mode).toEqual('absolute'); + test('returns brushHandler() should updates timefilter through vis.API.events.applyFilter', () => { + const gte = '2017-01-01T00:00:00Z'; + const lte = '2017-01-01T00:10:00Z'; + + onBrush(gte, lte); + + expect(applyFilter).toHaveBeenCalledWith({ + timeFieldName: '*', + filters: [ + { + range: { '*': { gte: '2017-01-01T00:00:00Z', lte: '2017-01-01T00:10:00Z' } }, + }, + ], + }); }); }); diff --git a/src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.js b/src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.ts similarity index 68% rename from src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.js rename to src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.ts index 452e85c6405fe..38002c7552952 100644 --- a/src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.js +++ b/src/plugins/vis_type_timeseries/public/application/lib/create_brush_handler.ts @@ -17,14 +17,23 @@ * under the License. */ -import moment from 'moment'; +import { ExprVisAPIEvents } from '../../../../visualizations/public'; -const TIME_MODE = 'absolute'; - -export const createBrushHandler = (timefilter) => (from, to) => { - timefilter.setTime({ - from: moment(from).toISOString(), - to: moment(to).toISOString(), - mode: TIME_MODE, +export const createBrushHandler = (applyFilter: ExprVisAPIEvents['applyFilter']) => ( + gte: string, + lte: string +) => { + return applyFilter({ + timeFieldName: '*', + filters: [ + { + range: { + '*': { + gte, + lte, + }, + }, + }, + ], }); }; diff --git a/src/plugins/vis_type_timeseries/public/metrics_type.ts b/src/plugins/vis_type_timeseries/public/metrics_type.ts index 44b0334a37871..d6621870fef67 100644 --- a/src/plugins/vis_type_timeseries/public/metrics_type.ts +++ b/src/plugins/vis_type_timeseries/public/metrics_type.ts @@ -25,6 +25,7 @@ import { EditorController } from './application'; // @ts-ignore import { PANEL_TYPES } from '../common/panel_types'; import { VisEditor } from './application/components/vis_editor_lazy'; +import { VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; export const metricsVisDefinition = { name: 'metrics', @@ -78,6 +79,9 @@ export const metricsVisDefinition = { showIndexSelection: false, }, requestHandler: metricsRequestHandler, + getSupportedTriggers: () => { + return [VIS_EVENT_TO_TRIGGER.applyFilter]; + }, inspectorAdapters: {}, responseHandler: 'none', }; diff --git a/src/plugins/vis_type_vega/public/vega_type.ts b/src/plugins/vis_type_vega/public/vega_type.ts index d69eb3cfba282..f49816017b684 100644 --- a/src/plugins/vis_type_vega/public/vega_type.ts +++ b/src/plugins/vis_type_vega/public/vega_type.ts @@ -27,6 +27,7 @@ import { createVegaRequestHandler } from './vega_request_handler'; import { createVegaVisualization } from './vega_visualization'; import { getDefaultSpec } from './default_spec'; import { createInspectorAdapters } from './vega_inspector'; +import { VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependencies) => { const requestHandler = createVegaRequestHandler(dependencies); @@ -54,6 +55,9 @@ export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependen showQueryBar: true, showFilterBar: true, }, + getSupportedTriggers: () => { + return [VIS_EVENT_TO_TRIGGER.applyFilter]; + }, stage: 'experimental', inspectorAdapters: createInspectorAdapters, }; diff --git a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js index 4596b47364494..a2a973d232de0 100644 --- a/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js +++ b/src/plugins/vis_type_vega/public/vega_view/vega_base_view.js @@ -63,6 +63,7 @@ export class VegaBaseView { this._parser = opts.vegaParser; this._serviceSettings = opts.serviceSettings; this._filterManager = opts.filterManager; + this._applyFilter = opts.applyFilter; this._timefilter = opts.timefilter; this._findIndex = opts.findIndex; this._view = null; @@ -263,7 +264,8 @@ export class VegaBaseView { async addFilterHandler(query, index) { const indexId = await this._findIndex(index); const filter = esFilters.buildQueryFilter(query, indexId); - this._filterManager.addFilters(filter); + + this._applyFilter({ filters: [filter] }); } /** @@ -298,7 +300,22 @@ export class VegaBaseView { * @param {number|string|Date} end */ setTimeFilterHandler(start, end) { - this._timefilter.setTime(VegaBaseView._parseTimeRange(start, end)); + const { from, to, mode } = VegaBaseView._parseTimeRange(start, end); + + this._applyFilter({ + timeFieldName: '*', + filters: [ + { + range: { + '*': { + mode, + gte: from, + lte: to, + }, + }, + }, + ], + }); } /** diff --git a/src/plugins/vis_type_vega/public/vega_visualization.js b/src/plugins/vis_type_vega/public/vega_visualization.js index 1fcb89f04457d..d6db0f9ea239f 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.js @@ -106,6 +106,7 @@ export const createVegaVisualization = ({ serviceSettings }) => const { timefilter } = this.dataPlugin.query.timefilter; const vegaViewParams = { parentEl: this._el, + applyFilter: this._vis.API.events.applyFilter, vegaParser, serviceSettings, filterManager, diff --git a/src/plugins/vis_type_vega/public/vega_visualization.test.js b/src/plugins/vis_type_vega/public/vega_visualization.test.js index 3e318fa22c195..0912edf9503a6 100644 --- a/src/plugins/vis_type_vega/public/vega_visualization.test.js +++ b/src/plugins/vis_type_vega/public/vega_visualization.test.js @@ -105,6 +105,11 @@ describe('VegaVisualizations', () => { vis = { type: vegaVisType, + API: { + events: { + applyFilter: jest.fn(), + }, + }, }; }); diff --git a/src/plugins/visualizations/public/embeddable/events.ts b/src/plugins/visualizations/public/embeddable/events.ts index 0957895a21403..52cac59fbffaa 100644 --- a/src/plugins/visualizations/public/embeddable/events.ts +++ b/src/plugins/visualizations/public/embeddable/events.ts @@ -17,14 +17,20 @@ * under the License. */ -import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER } from '../../../../plugins/ui_actions/public'; +import { + APPLY_FILTER_TRIGGER, + SELECT_RANGE_TRIGGER, + VALUE_CLICK_TRIGGER, +} from '../../../../plugins/ui_actions/public'; export interface VisEventToTrigger { + ['applyFilter']: typeof APPLY_FILTER_TRIGGER; ['brush']: typeof SELECT_RANGE_TRIGGER; ['filter']: typeof VALUE_CLICK_TRIGGER; } export const VIS_EVENT_TO_TRIGGER: VisEventToTrigger = { + applyFilter: APPLY_FILTER_TRIGGER, brush: SELECT_RANGE_TRIGGER, filter: VALUE_CLICK_TRIGGER, }; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index cc24c0509fbc1..80e577930fa8d 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -310,12 +310,21 @@ export class VisualizeEmbeddable extends Embeddable void; brush: (data: any) => void; + applyFilter: (data: any) => void; } export interface ExprVisAPI { @@ -83,6 +84,10 @@ export class ExprVis extends EventEmitter { if (!this.eventsSubject) return; this.eventsSubject.next({ name: 'brush', data }); }, + applyFilter: (data: any) => { + if (!this.eventsSubject) return; + this.eventsSubject.next({ name: 'applyFilter', data }); + }, }, }; } diff --git a/src/plugins/visualizations/public/index.ts b/src/plugins/visualizations/public/index.ts index 2ac53c2c81acc..49cfbe76aa9d0 100644 --- a/src/plugins/visualizations/public/index.ts +++ b/src/plugins/visualizations/public/index.ts @@ -51,5 +51,6 @@ export { VisSavedObject, VisResponseValue, } from './types'; +export { ExprVisAPIEvents } from './expressions/vis'; export { VisualizationListItem } from './vis_types/vis_type_alias_registry'; export { VISUALIZE_ENABLE_LABS_SETTING } from '../common/constants'; From 187a13075b5fe03a165be68faa9c31516ab74a28 Mon Sep 17 00:00:00 2001 From: Nathan Reese Date: Fri, 14 Aug 2020 07:15:16 -0600 Subject: [PATCH 13/51] [file upload] lazy load to reduce page load size (#74967) * [file upload] lazy load to reduce page load size * tslint --- .../public/get_file_upload_component.ts | 38 +++++++++++++++++++ x-pack/plugins/file_upload/public/index.ts | 2 + x-pack/plugins/file_upload/public/plugin.ts | 6 +-- .../layers/file_upload_wizard/wizard.tsx | 19 +++++++++- .../plugins/maps/public/kibana_services.d.ts | 4 +- x-pack/plugins/maps/public/kibana_services.js | 4 +- 6 files changed, 64 insertions(+), 9 deletions(-) create mode 100644 x-pack/plugins/file_upload/public/get_file_upload_component.ts diff --git a/x-pack/plugins/file_upload/public/get_file_upload_component.ts b/x-pack/plugins/file_upload/public/get_file_upload_component.ts new file mode 100644 index 0000000000000..7232c4126e297 --- /dev/null +++ b/x-pack/plugins/file_upload/public/get_file_upload_component.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { FeatureCollection } from 'geojson'; + +export interface FileUploadComponentProps { + appName: string; + isIndexingTriggered: boolean; + onFileUpload: (geojsonFile: FeatureCollection, name: string) => void; + onFileRemove: () => void; + onIndexReady: (indexReady: boolean) => void; + transformDetails: string; + onIndexingComplete: (indexResponses: { + indexDataResp: unknown; + indexPatternResp: unknown; + }) => void; +} + +let lazyLoadPromise: Promise>; + +export async function getFileUploadComponent(): Promise< + React.ComponentType +> { + if (typeof lazyLoadPromise !== 'undefined') { + return lazyLoadPromise; + } + + lazyLoadPromise = new Promise(async (resolve) => { + // @ts-expect-error + const { JsonUploadAndParse } = await import('./components/json_upload_and_parse'); + resolve(JsonUploadAndParse); + }); + return lazyLoadPromise; +} diff --git a/x-pack/plugins/file_upload/public/index.ts b/x-pack/plugins/file_upload/public/index.ts index 205ceae37d6a1..1e39fb4dc8596 100644 --- a/x-pack/plugins/file_upload/public/index.ts +++ b/x-pack/plugins/file_upload/public/index.ts @@ -9,3 +9,5 @@ import { FileUploadPlugin } from './plugin'; export function plugin() { return new FileUploadPlugin(); } + +export { FileUploadComponentProps } from './get_file_upload_component'; diff --git a/x-pack/plugins/file_upload/public/plugin.ts b/x-pack/plugins/file_upload/public/plugin.ts index 338c61ad141c6..ff74be659aeca 100644 --- a/x-pack/plugins/file_upload/public/plugin.ts +++ b/x-pack/plugins/file_upload/public/plugin.ts @@ -4,10 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -// @ts-ignore import { CoreSetup, CoreStart, Plugin } from 'kibana/server'; -// @ts-ignore -import { JsonUploadAndParse } from './components/json_upload_and_parse'; +import { getFileUploadComponent } from './get_file_upload_component'; // @ts-ignore import { setupInitServicesAndConstants, startInitServicesAndConstants } from './kibana_services'; import { IDataPluginServices } from '../../../../src/plugins/data/public'; @@ -35,7 +33,7 @@ export class FileUploadPlugin implements Plugin | null; } export class ClientFileCreateSourceEditor extends Component { private _isMounted: boolean = false; - state = { + state: State = { indexingStage: null, + fileUploadComponent: null, }; componentDidMount() { this._isMounted = true; + this._loadFileUploadComponent(); } componentWillUnmount() { @@ -59,6 +63,13 @@ export class ClientFileCreateSourceEditor extends Component { if (!this._isMounted) { return; @@ -145,7 +156,11 @@ export class ClientFileCreateSourceEditor extends Component >; export function getIndexPatternSelectComponent(): any; export function getHttp(): any; export function getTimeFilter(): any; diff --git a/x-pack/plugins/maps/public/kibana_services.js b/x-pack/plugins/maps/public/kibana_services.js index 9b035a87a3b37..64aa0e07ffafb 100644 --- a/x-pack/plugins/maps/public/kibana_services.js +++ b/x-pack/plugins/maps/public/kibana_services.js @@ -33,8 +33,8 @@ export const getInspector = () => { let fileUploadPlugin; export const setFileUpload = (fileUpload) => (fileUploadPlugin = fileUpload); -export const getFileUploadComponent = () => { - return fileUploadPlugin.JsonUploadAndParse; +export const getFileUploadComponent = async () => { + return await fileUploadPlugin.getFileUploadComponent(); }; let uiSettings; From 8aa8b04cee7eceac3fb87ff19d5ab2c12a9c2a26 Mon Sep 17 00:00:00 2001 From: Kevin Logan <56395104+kevinlog@users.noreply.github.com> Date: Fri, 14 Aug 2020 10:06:51 -0400 Subject: [PATCH 14/51] [SECURITY_SOLUTION] Retry on ingest setup (#75000) --- x-pack/test/common/services/ingest_manager.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/x-pack/test/common/services/ingest_manager.ts b/x-pack/test/common/services/ingest_manager.ts index 96b1b97a68dc9..2fcfaa014b2e1 100644 --- a/x-pack/test/common/services/ingest_manager.ts +++ b/x-pack/test/common/services/ingest_manager.ts @@ -8,15 +8,18 @@ import { fleetSetupRouteService } from '../../../plugins/ingest_manager/common'; export function IngestManagerProvider({ getService }: FtrProviderContext) { const supertest = getService('supertest'); + const retry = getService('retry'); return { async setup() { const headers = { accept: 'application/json', 'kbn-xsrf': 'some-xsrf-token' }; - await supertest - .post(fleetSetupRouteService.postFleetSetupPath()) - .set(headers) - .send({ forceRecreate: true }) - .expect(200); + await retry.try(async () => { + await supertest + .post(fleetSetupRouteService.postFleetSetupPath()) + .set(headers) + .send({ forceRecreate: true }) + .expect(200); + }); }, }; } From ec5112b9cc6dcdb37242407925d6c0f2a10ffe50 Mon Sep 17 00:00:00 2001 From: Wylie Conlon Date: Fri, 14 Aug 2020 10:54:43 -0400 Subject: [PATCH 15/51] [Lens] Fix table sorting bug (#74902) * [Lens] Fix table sorting bug * Fix types --- .../visualization.test.tsx | 25 +++++++++++++++++++ .../datatable_visualization/visualization.tsx | 8 ++++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx index e18190b6c2d69..0b6584277ffa7 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.test.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Ast } from '@kbn/interpreter/common'; +import { buildExpression } from '../../../../../src/plugins/expressions/public'; import { createMockDatasource } from '../editor_frame_service/mocks'; import { DatatableVisualizationState, datatableVisualization } from './visualization'; import { Operation, DataType, FramePublicAPI, TableSuggestionColumn } from '../types'; @@ -324,4 +326,27 @@ describe('Datatable Visualization', () => { }); }); }); + + describe('#toExpression', () => { + it('reorders the rendered colums based on the order from the datasource', () => { + const datasource = createMockDatasource('test'); + const layer = { layerId: 'a', columns: ['b', 'c'] }; + const frame = mockFrame(); + frame.datasourceLayers = { a: datasource.publicAPIMock }; + datasource.publicAPIMock.getTableSpec.mockReturnValue([{ columnId: 'c' }, { columnId: 'b' }]); + datasource.publicAPIMock.getOperationForColumnId.mockReturnValue({ + dataType: 'string', + isBucketed: true, + label: 'label', + }); + + const expression = datatableVisualization.toExpression({ layers: [layer] }, frame) as Ast; + const tableArgs = buildExpression(expression).findFunction('lens_datatable_columns'); + + expect(tableArgs).toHaveLength(1); + expect(tableArgs[0].arguments).toEqual({ + columnIds: ['c', 'b'], + }); + }); + }); }); diff --git a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx index e4b371143594a..659f8ea12bcb0 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/visualization.tsx @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Ast } from '@kbn/interpreter/common'; import { i18n } from '@kbn/i18n'; import { SuggestionRequest, Visualization, VisualizationSuggestion, Operation } from '../types'; import chartTableSVG from '../assets/chart_datatable.svg'; @@ -185,10 +186,13 @@ export const datatableVisualization: Visualization< }; }, - toExpression(state, frame) { + toExpression(state, frame): Ast { const layer = state.layers[0]; const datasource = frame.datasourceLayers[layer.layerId]; - const operations = layer.columns + const originalOrder = datasource.getTableSpec().map(({ columnId }) => columnId); + // When we add a column it could be empty, and therefore have no order + const sortedColumns = Array.from(new Set(originalOrder.concat(layer.columns))); + const operations = sortedColumns .map((columnId) => ({ columnId, operation: datasource.getOperationForColumnId(columnId) })) .filter((o): o is { columnId: string; operation: Operation } => !!o.operation); From 458bf9fb0db04a7648f9466520a6bdae2004e2b3 Mon Sep 17 00:00:00 2001 From: Stratoula Kalafateli Date: Fri, 14 Aug 2020 17:57:08 +0300 Subject: [PATCH 16/51] Fix bug on TopN weird behavior with zero values (#74942) --- .../public/application/visualizations/views/top_n.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js index b595979130d3a..3aae1bd64d953 100644 --- a/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js +++ b/src/plugins/vis_type_timeseries/public/application/visualizations/views/top_n.js @@ -110,7 +110,9 @@ export class TopN extends Component { const isPositiveValue = lastValue >= 0; const intervalLength = TopN.calcDomain(renderMode, min, max); - const width = 100 * (Math.abs(lastValue) / intervalLength); + // if both are 0, the division returns NaN causing unexpected behavior. + // For this it defaults to 0 + const width = 100 * (Math.abs(lastValue) / intervalLength) || 0; const styles = reactcss( { From 344e1de7e1a0962c4e4c1ebf2ed81fccebf84b15 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Fri, 14 Aug 2020 17:14:03 +0200 Subject: [PATCH 17/51] [Upgrade Assistant] Fix potentially long URI (#74485) * _cluster/state/metadata/* and added a comment to function * add another comment regarding why we are asking for * * update jest test * refactor and clean up use of cluster status to get index state Co-authored-by: Elastic Machine --- .../common/get_index_state.test.ts | 41 +++++++++++++ .../common/get_index_state.ts | 24 ++++++++ ...get_index_state_from_cluster_state.test.ts | 53 ----------------- .../get_index_state_from_cluster_state.ts | 28 --------- .../plugins/upgrade_assistant/common/types.ts | 50 +++++----------- .../server/lib/es_indices_state_check.ts | 21 ++++--- .../server/lib/es_migration_apis.test.ts | 28 ++++----- .../server/lib/es_migration_apis.ts | 2 +- .../server/lib/reindexing/reindex_service.ts | 6 +- .../upgrade_assistant/index.js | 1 - .../upgrade_assistant/reindexing.js | 16 ++--- .../upgrade_assistant/status.ts | 58 ------------------- 12 files changed, 108 insertions(+), 220 deletions(-) create mode 100644 x-pack/plugins/upgrade_assistant/common/get_index_state.test.ts create mode 100644 x-pack/plugins/upgrade_assistant/common/get_index_state.ts delete mode 100644 x-pack/plugins/upgrade_assistant/common/get_index_state_from_cluster_state.test.ts delete mode 100644 x-pack/plugins/upgrade_assistant/common/get_index_state_from_cluster_state.ts delete mode 100644 x-pack/test/upgrade_assistant_integration/upgrade_assistant/status.ts diff --git a/x-pack/plugins/upgrade_assistant/common/get_index_state.test.ts b/x-pack/plugins/upgrade_assistant/common/get_index_state.test.ts new file mode 100644 index 0000000000000..43c20add0bb3c --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/common/get_index_state.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { getIndexState } from './get_index_state'; +import { ResolveIndexResponseFromES } from './types'; + +describe('getIndexState', () => { + const indexName1 = 'indexName'; + const indexName2 = 'indexName2'; + const response: ResolveIndexResponseFromES = { + indices: [ + { + name: indexName2, + aliases: ['.security'], + attributes: ['open'], + }, + { + name: indexName1, + attributes: ['closed'], + }, + ], + aliases: [ + { + name: '.security', + indices: ['.security-7'], + }, + ], + data_streams: [], + }; + + it('correctly extracts state', () => { + expect(getIndexState(indexName1, response)).toBe('closed'); + expect(getIndexState(indexName2, response)).toBe('open'); + }); + + it('throws if the index name cannot be found', () => { + expect(() => getIndexState('nonExistent', response)).toThrow('not found'); + }); +}); diff --git a/x-pack/plugins/upgrade_assistant/common/get_index_state.ts b/x-pack/plugins/upgrade_assistant/common/get_index_state.ts new file mode 100644 index 0000000000000..24d044f324d07 --- /dev/null +++ b/x-pack/plugins/upgrade_assistant/common/get_index_state.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ResolveIndexResponseFromES } from './types'; + +/** + * Throws if the index name is not found in the resolved indices response + * + * @param indexName Assume this is an index name, not an alias + * @param resolvedResponse The response from _resolve/index/ + */ +export const getIndexState = ( + indexName: string, + resolvedResponse: ResolveIndexResponseFromES +): 'open' | 'closed' => { + const index = resolvedResponse.indices.find((i) => i.name === indexName); + if (index) { + return index.attributes.includes('closed') ? 'closed' : 'open'; + } + throw new Error(`${indexName} not found!`); +}; diff --git a/x-pack/plugins/upgrade_assistant/common/get_index_state_from_cluster_state.test.ts b/x-pack/plugins/upgrade_assistant/common/get_index_state_from_cluster_state.test.ts deleted file mode 100644 index 1098594a68f8a..0000000000000 --- a/x-pack/plugins/upgrade_assistant/common/get_index_state_from_cluster_state.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { getIndexStateFromClusterState } from './get_index_state_from_cluster_state'; -import { ClusterStateAPIResponse } from './types'; - -describe('getIndexStateFromClusterState', () => { - const indexName = 'indexName'; - const clusterState: ClusterStateAPIResponse = { - metadata: { - indices: {}, - cluster_coordination: {} as any, - cluster_uuid: 'test', - templates: {} as any, - }, - cluster_name: 'test', - cluster_uuid: 'test', - }; - - afterEach(() => { - clusterState.metadata.indices = {}; - }); - - it('correctly extracts state from cluster state', () => { - clusterState.metadata.indices[indexName] = { state: 'open' } as any; - clusterState.metadata.indices.aTotallyDifferentIndex = { state: 'close' } as any; - expect(getIndexStateFromClusterState(indexName, clusterState)).toBe('open'); - }); - - it('correctly extracts state from aliased index in cluster state', () => { - clusterState.metadata.indices.aTotallyDifferentName = { - state: 'close', - aliases: [indexName, 'test'], - } as any; - clusterState.metadata.indices.aTotallyDifferentName1 = { - state: 'open', - aliases: ['another', 'test'], - } as any; - - expect(getIndexStateFromClusterState(indexName, clusterState)).toBe('close'); - }); - - it('throws if the index name cannot be found in the cluster state', () => { - expect(() => getIndexStateFromClusterState(indexName, clusterState)).toThrow('not found'); - clusterState.metadata.indices.aTotallyDifferentName1 = { - state: 'open', - aliases: ['another', 'test'], - } as any; - expect(() => getIndexStateFromClusterState(indexName, clusterState)).toThrow('not found'); - }); -}); diff --git a/x-pack/plugins/upgrade_assistant/common/get_index_state_from_cluster_state.ts b/x-pack/plugins/upgrade_assistant/common/get_index_state_from_cluster_state.ts deleted file mode 100644 index f302940424ee3..0000000000000 --- a/x-pack/plugins/upgrade_assistant/common/get_index_state_from_cluster_state.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { ClusterStateAPIResponse } from './types'; - -const checkAllAliases = ( - indexName: string, - clusterState: ClusterStateAPIResponse -): 'open' | 'close' => { - for (const index of Object.values(clusterState.metadata.indices)) { - if (index.aliases?.some((alias) => alias === indexName)) { - return index.state; - } - } - - throw new Error(`${indexName} not found in cluster state!`); -}; - -export const getIndexStateFromClusterState = ( - indexName: string, - clusterState: ClusterStateAPIResponse -): 'open' | 'close' => - clusterState.metadata.indices[indexName] - ? clusterState.metadata.indices[indexName].state - : checkAllAliases(indexName, clusterState); diff --git a/x-pack/plugins/upgrade_assistant/common/types.ts b/x-pack/plugins/upgrade_assistant/common/types.ts index 6c1b24b677754..a29c37c9a988c 100644 --- a/x-pack/plugins/upgrade_assistant/common/types.ts +++ b/x-pack/plugins/upgrade_assistant/common/types.ts @@ -184,41 +184,17 @@ export interface UpgradeAssistantStatus { indices: EnrichedDeprecationInfo[]; } -export interface ClusterStateIndexAPIResponse { - state: 'open' | 'close'; - settings: { - index: { - verified_before_close: string; - search: { - throttled: string; - }; - number_of_shards: string; - provided_name: string; - frozen: string; - creation_date: string; - number_of_replicas: string; - uuid: string; - version: { - created: string; - }; - }; - }; - mappings: any; - aliases: string[]; -} - -export interface ClusterStateAPIResponse { - cluster_name: string; - cluster_uuid: string; - metadata: { - cluster_uuid: string; - cluster_coordination: { - term: number; - last_committed_config: string[]; - last_accepted_config: string[]; - voting_config_exclusions: []; - }; - templates: any; - indices: { [indexName: string]: ClusterStateIndexAPIResponse }; - }; +export interface ResolveIndexResponseFromES { + indices: Array<{ + name: string; + // per https://github.com/elastic/elasticsearch/pull/57626 + attributes: Array<'open' | 'closed' | 'hidden' | 'frozen'>; + aliases?: string[]; + data_stream?: string; + }>; + aliases: Array<{ + name: string; + indices: string[]; + }>; + data_streams: Array<{ name: string; backing_indices: string[]; timestamp_field: string }>; } diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_indices_state_check.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_indices_state_check.ts index cec89bbe2745e..bce48b152700f 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_indices_state_check.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_indices_state_check.ts @@ -5,28 +5,27 @@ */ import { LegacyAPICaller } from 'kibana/server'; -import { getIndexStateFromClusterState } from '../../common/get_index_state_from_cluster_state'; -import { ClusterStateAPIResponse } from '../../common/types'; +import { getIndexState } from '../../common/get_index_state'; +import { ResolveIndexResponseFromES } from '../../common/types'; -type StatusCheckResult = Record; +type StatusCheckResult = Record; export const esIndicesStateCheck = async ( callAsUser: LegacyAPICaller, indices: string[] ): Promise => { - // According to https://www.elastic.co/guide/en/elasticsearch/reference/7.6/cluster-state.html - // The response from this call is considered internal and subject to change. We have an API - // integration test for asserting that the current ES version still returns what we expect. - // This lives in x-pack/test/upgrade_assistant_integration - const clusterState: ClusterStateAPIResponse = await callAsUser('cluster.state', { - index: indices, - metric: 'metadata', + const response: ResolveIndexResponseFromES = await callAsUser('transport.request', { + method: 'GET', + path: `/_resolve/index/*`, + query: { + expand_wildcards: 'all', + }, }); const result: StatusCheckResult = {}; indices.forEach((index) => { - result[index] = getIndexStateFromClusterState(index, clusterState); + result[index] = getIndexState(index, response); }); return result; diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts index 2a4fa5cd48ded..6e524a98afdc6 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.test.ts @@ -6,33 +6,25 @@ import _ from 'lodash'; import { elasticsearchServiceMock } from 'src/core/server/mocks'; -import { getUpgradeAssistantStatus } from './es_migration_apis'; - import { DeprecationAPIResponse } from 'src/legacy/core_plugins/elasticsearch'; + +import { getUpgradeAssistantStatus } from './es_migration_apis'; import fakeDeprecations from './__fixtures__/fake_deprecations.json'; +const fakeIndexNames = Object.keys(fakeDeprecations.index_settings); + describe('getUpgradeAssistantStatus', () => { + const resolvedIndices = { + indices: fakeIndexNames.map((f) => ({ name: f, attributes: ['open'] })), + }; let deprecationsResponse: DeprecationAPIResponse; const dataClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); - (dataClient.callAsCurrentUser as jest.Mock).mockImplementation(async (api, { path, index }) => { + (dataClient.callAsCurrentUser as jest.Mock).mockImplementation(async (api, { path }) => { if (path === '/_migration/deprecations') { return deprecationsResponse; - } else if (api === 'cluster.state') { - return { - metadata: { - indices: { - ...index.reduce((acc: any, i: any) => { - return { - ...acc, - [i]: { - state: 'open', - }, - }; - }, {}), - }, - }, - }; + } else if (path === '/_resolve/index/*') { + return resolvedIndices; } else if (api === 'indices.getMapping') { return {}; } else { diff --git a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts index 98175e2ae791e..abbeb8a89e12a 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/es_migration_apis.ts @@ -34,7 +34,7 @@ export async function getUpgradeAssistantStatus( indices.forEach((indexData) => { indexData.blockerForReindexing = - indexStates[indexData.index!] === 'close' ? 'index-closed' : undefined; + indexStates[indexData.index!] === 'closed' ? 'index-closed' : undefined; }); } diff --git a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts index a23ddc0d60329..e784f42867d57 100644 --- a/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts +++ b/x-pack/plugins/upgrade_assistant/server/lib/reindexing/reindex_service.ts @@ -346,8 +346,8 @@ export const reindexServiceFactory = ( // Where possible, derive reindex options at the last moment before reindexing // to prevent them from becoming stale as they wait in the queue. const indicesState = await esIndicesStateCheck(callAsUser, [indexName]); - const openAndClose = indicesState[indexName] === 'close'; - if (indicesState[indexName] === 'close') { + const shouldOpenAndClose = indicesState[indexName] === 'closed'; + if (shouldOpenAndClose) { log.debug(`Detected closed index ${indexName}, opening...`); await callAsUser('indices.open', { index: indexName }); } @@ -369,7 +369,7 @@ export const reindexServiceFactory = ( ...(reindexOptions ?? {}), // Indicate to downstream states whether we opened a closed index that should be // closed again. - openAndClose, + openAndClose: shouldOpenAndClose, }, }); }; diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js index 638f31dc211b5..1b7406b37022a 100644 --- a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js +++ b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/index.js @@ -9,6 +9,5 @@ export default function ({ loadTestFile }) { this.tags('ciGroup7'); loadTestFile(require.resolve('./reindexing')); - loadTestFile(require.resolve('./status')); }); } diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js index 1131089130d36..10074f68bb59e 100644 --- a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js +++ b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/reindexing.js @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { ReindexStatus, REINDEX_OP_TYPE } from '../../../plugins/upgrade_assistant/common/types'; import { generateNewIndexName } from '../../../plugins/upgrade_assistant/server/lib/reindexing/index_settings'; -import { getIndexStateFromClusterState } from '../../../plugins/upgrade_assistant/common/get_index_state_from_cluster_state'; +import { getIndexState } from '../../../plugins/upgrade_assistant/common/get_index_state'; export default function ({ getService }) { const supertest = getService('supertest'); @@ -190,7 +190,7 @@ export default function ({ getService }) { expect(result.body.enqueued.length).to.equal(3); expect(result.body.errors.length).to.equal(0); - const [{ newIndexName: newTest1Name }] = result.body.enqueued; + const [{ newIndexName: nameOfIndexThatShouldBeClosed }] = result.body.enqueued; await assertQueueState(test1, 3); await waitForReindexToComplete(test1); @@ -204,16 +204,12 @@ export default function ({ getService }) { await assertQueueState(undefined, 0); // Check that the closed index is still closed after reindexing - const clusterStateResponse = await es.cluster.state({ - index: newTest1Name, - metric: 'metadata', + const resolvedIndices = await es.transport.request({ + path: `_resolve/index/${nameOfIndexThatShouldBeClosed}`, }); - const test1ReindexedState = getIndexStateFromClusterState( - newTest1Name, - clusterStateResponse - ); - expect(test1ReindexedState).to.be('close'); + const test1ReindexedState = getIndexState(nameOfIndexThatShouldBeClosed, resolvedIndices); + expect(test1ReindexedState).to.be('closed'); } finally { await cleanupReindex(test1); await cleanupReindex(test2); diff --git a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/status.ts b/x-pack/test/upgrade_assistant_integration/upgrade_assistant/status.ts deleted file mode 100644 index d13b9836f25a1..0000000000000 --- a/x-pack/test/upgrade_assistant_integration/upgrade_assistant/status.ts +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import expect from '@kbn/expect'; -import { FtrProviderContext } from '../../api_integration/ftr_provider_context'; -import { ClusterStateAPIResponse } from '../../../plugins/upgrade_assistant/common/types'; -import { getIndexStateFromClusterState } from '../../../plugins/upgrade_assistant/common/get_index_state_from_cluster_state'; - -// eslint-disable-next-line import/no-default-export -export default function ({ getService }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const es = getService('es'); - - describe('status and _cluster/state contract', () => { - beforeEach(async () => { - await es.indices.open({ index: '7.0-data' }); - }); - - afterEach(async () => { - await es.indices.open({ index: '7.0-data' }); - }); - - // According to https://www.elastic.co/guide/en/elasticsearch/reference/7.6/cluster-state.html - // The response from this call is considered internal and subject to change. We check that - // the contract has not changed in this integration test. - it('the _cluster/state endpoint is still what we expect', async () => { - await esArchiver.load('upgrade_assistant/reindex'); - await es.indices.close({ index: '7.0-data' }); - const result = await es.cluster.state({ - index: '7.0-data', - metric: 'metadata', - }); - - try { - if (getIndexStateFromClusterState('7.0-data', result.body) === 'close') { - return; - } - } catch (e) { - expect().fail( - `Can no longer access index open/closed state. Please update Upgrade Assistant checkup. (${e.message})` - ); - return; - } - expect().fail( - `The response contract for _cluster/state metadata has changed. Please update Upgrade Assistant checkup. Received ${JSON.stringify( - result, - null, - 2 - )}. - -Expected body.metadata.indices['7.0-data'].state to be "close".` - ); - }); - }); -} From f1ad1f1b7bd11ebf8b25d3ea95e615b92a205547 Mon Sep 17 00:00:00 2001 From: Quynh Nguyen <43350163+qn895@users.noreply.github.com> Date: Fri, 14 Aug 2020 10:34:08 -0500 Subject: [PATCH 18/51] [ML] Add new storeInHashQuery and replaceUrlQuery (#74955) Co-authored-by: Elastic Machine --- .../public/state_management/url/format.ts | 17 +++++++++++++++ .../url/kbn_url_storage.test.ts | 21 +++++++++++++++++++ .../state_management/url/kbn_url_storage.ts | 10 ++++++--- 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/plugins/kibana_utils/public/state_management/url/format.ts b/src/plugins/kibana_utils/public/state_management/url/format.ts index 2912b665ff014..4497e509bc86b 100644 --- a/src/plugins/kibana_utils/public/state_management/url/format.ts +++ b/src/plugins/kibana_utils/public/state_management/url/format.ts @@ -22,6 +22,23 @@ import { stringify, ParsedQuery } from 'query-string'; import { parseUrl, parseUrlHash } from './parse'; import { url as urlUtils } from '../../../common'; +export function replaceUrlQuery( + rawUrl: string, + queryReplacer: (query: ParsedQuery) => ParsedQuery +) { + const url = parseUrl(rawUrl); + const newQuery = queryReplacer(url.query || {}); + const searchQueryString = stringify(urlUtils.encodeQuery(newQuery), { + sort: false, + encode: false, + }); + if (!url.search && !searchQueryString) return rawUrl; // nothing to change. return original url + return formatUrl({ + ...url, + search: searchQueryString, + }); +} + export function replaceUrlHashQuery( rawUrl: string, queryReplacer: (query: ParsedQuery) => ParsedQuery diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts index a8c3aab2202d1..3d25134cd178d 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.test.ts @@ -78,6 +78,27 @@ describe('kbn_url_storage', () => { const retrievedState2 = getStateFromKbnUrl('_s', newUrl); expect(retrievedState2).toEqual(state2); }); + + it('should set query to url with storeInHashQuery: false', () => { + let newUrl = setStateToKbnUrl( + '_a', + { tab: 'other' }, + { useHash: false, storeInHashQuery: false }, + 'http://localhost:5601/oxf/app/kibana/yourApp' + ); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana/yourApp?_a=(tab:other)"` + ); + newUrl = setStateToKbnUrl( + '_b', + { f: 'test', i: '', l: '' }, + { useHash: false, storeInHashQuery: false }, + newUrl + ); + expect(newUrl).toMatchInlineSnapshot( + `"http://localhost:5601/oxf/app/kibana/yourApp?_a=(tab:other)&_b=(f:test,i:'',l:'')"` + ); + }); }); describe('urlControls', () => { diff --git a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts index fefd5f668c6b3..a3b220f911504 100644 --- a/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts +++ b/src/plugins/kibana_utils/public/state_management/url/kbn_url_storage.ts @@ -22,7 +22,7 @@ import { stringify } from 'query-string'; import { createBrowserHistory, History } from 'history'; import { decodeState, encodeState } from '../state_encoder'; import { getCurrentUrl, parseUrl, parseUrlHash } from './parse'; -import { replaceUrlHashQuery } from './format'; +import { replaceUrlHashQuery, replaceUrlQuery } from './format'; import { url as urlUtils } from '../../../common'; /** @@ -84,10 +84,14 @@ export function getStateFromKbnUrl( export function setStateToKbnUrl( key: string, state: State, - { useHash = false }: { useHash: boolean } = { useHash: false }, + { useHash = false, storeInHashQuery = true }: { useHash: boolean; storeInHashQuery?: boolean } = { + useHash: false, + storeInHashQuery: true, + }, rawUrl = window.location.href ): string { - return replaceUrlHashQuery(rawUrl, (query) => { + const replacer = storeInHashQuery ? replaceUrlHashQuery : replaceUrlQuery; + return replacer(rawUrl, (query) => { const encoded = encodeState(state, useHash); return { ...query, From bbee1f92b0879e8e4ed50a34e74edde7fe83e0c2 Mon Sep 17 00:00:00 2001 From: Greg Thompson Date: Fri, 14 Aug 2020 10:31:15 -0600 Subject: [PATCH 19/51] Upgrade EUI to v27.4.0 (#74004) * eui to 27.1.0 * eui to 27.2.0 * buttoniconside type * euiselectable type * update onScroll callback and polyfill size references * findTestSubject ts * buttoncontent and collapsiblenav src snapshot updates * update prop retrieval * xpack snapshots * jest updates * type fixes * more snapshots * virtual list changes * more virtualization changes * merge * fix functional tests * data-test-subj for indexPatter-switcher * storyshots * eui to 27.3.1 * Fix unit tests * Fix broken unit test * Updated snapshots * Fixed types * search for value in euiselectable before selection * select the correct element * mock virtualized dep * ts fix * reinstate storyshot * ts fix Co-authored-by: Chandler Prall Co-authored-by: Elastic Machine --- package.json | 2 +- packages/kbn-ui-shared-deps/package.json | 2 +- .../collapsible_nav.test.tsx.snap | 9557 ++++++++++------- .../header/__snapshots__/header.test.tsx.snap | 1724 ++- .../__snapshots__/modal_service.test.tsx.snap | 4 +- .../components/field/field.test.tsx | 1 - .../components/form/form.test.tsx | 1 - .../components/search/search.test.tsx | 1 - .../dashboard_empty_screen.test.tsx.snap | 65 +- .../dashboard_empty_screen.test.tsx | 1 - .../viewport/dashboard_viewport.test.tsx | 1 - .../tests/dashboard_container.test.tsx | 1 - .../shard_failure_open_modal_button.test.tsx | 1 - .../components/action_bar/action_bar.test.tsx | 1 - .../pager/tool_bar_pager_buttons.test.tsx | 1 - .../table_header/table_header.test.tsx | 1 - .../context_error_message.test.tsx | 1 - .../application/components/doc/doc.test.tsx | 1 - .../components/doc_viewer/doc_viewer.test.tsx | 1 - .../hits_counter/hits_counter.test.tsx | 1 - .../loading_spinner/loading_spinner.test.tsx | 1 - .../sidebar/discover_field.test.tsx | 1 - .../sidebar/discover_field_search.test.tsx | 1 - .../sidebar/discover_index_pattern.test.tsx | 4 +- .../sidebar/discover_sidebar.test.tsx | 1 - .../skip_bottom_button.test.tsx | 2 - .../components/table/table.test.tsx | 1 - .../timechart_header.test.tsx | 1 - .../lib/embeddables/embeddable_root.test.tsx | 1 - .../lib/panel/embeddable_panel.test.tsx | 1 - .../add_panel/add_panel_flyout.test.tsx | 3 +- .../customize_panel_action.test.ts | 2 - .../tests/customize_panel_modal.test.tsx | 1 - .../saved_objects_installer.test.js.snap | 78 +- .../header/__snapshots__/header.test.tsx.snap | 2 +- .../empty_state/empty_state.test.tsx | 1 - .../components/editor/controls_tab.test.tsx | 1 - .../editor/list_control_editor.test.tsx | 1 - .../editor/range_control_editor.test.tsx | 1 - .../components/vis/input_control_vis.test.tsx | 1 - .../inspector_panel.test.tsx.snap | 63 +- .../public/top_nav_menu/top_nav_menu_data.tsx | 4 +- .../__snapshots__/header.test.tsx.snap | 165 +- .../objects_table/components/table.test.tsx | 1 - .../components/color_picker.test.tsx | 1 - .../legend/__snapshots__/legend.test.tsx.snap | 2 +- .../__snapshots__/new_vis_modal.test.tsx.snap | 4284 +++++--- .../apps/dashboard/dashboard_filter_bar.js | 2 +- .../input_control_vis/chained_controls.js | 2 +- .../input_control_vis/dynamic_options.js | 8 +- test/functional/page_objects/discover_page.ts | 1 + .../plugins/kbn_tp_run_pipeline/package.json | 2 +- .../kbn_sample_panel_action/package.json | 2 +- .../kbn_tp_custom_visualizations/package.json | 2 +- x-pack/package.json | 2 +- .../__test__/__snapshots__/List.test.tsx.snap | 18 +- .../ServiceOverview.test.tsx.snap | 5 +- .../TransactionActionMenu.test.tsx.snap | 12 +- .../time_filter.stories.storyshot | 40 +- .../asset_manager.stories.storyshot | 16 +- .../custom_element_modal.stories.storyshot | 44 +- .../element_card.stories.storyshot | 8 +- .../keyboard_shortcuts_doc.stories.storyshot | 5 + .../element_grid.stories.storyshot | 6 +- .../saved_elements_modal.stories.storyshot | 29 +- .../text_style_picker.stories.storyshot | 60 +- .../__snapshots__/toolbar.stories.storyshot | 19 +- .../delete_var.stories.storyshot | 7 +- .../__snapshots__/edit_var.stories.storyshot | 34 +- .../__snapshots__/edit_menu.stories.storyshot | 12 +- .../element_menu.stories.storyshot | 5 +- .../__snapshots__/pdf_panel.stories.storyshot | 7 +- .../share_menu.stories.storyshot | 2 +- .../__snapshots__/view_menu.stories.storyshot | 8 +- .../workpad_templates.stories.storyshot | 22 +- .../__snapshots__/shareable.test.tsx.snap | 10 +- .../__snapshots__/canvas.stories.storyshot | 6 +- .../__tests__/__snapshots__/app.test.tsx.snap | 2 +- .../__snapshots__/footer.stories.storyshot | 4 +- .../page_controls.stories.storyshot | 6 +- .../autoplay_settings.stories.storyshot | 6 +- .../__snapshots__/settings.test.tsx.snap | 2 +- .../__snapshots__/policy_table.test.js.snap | 5 +- .../layerpanel.test.tsx | 23 +- .../__snapshots__/add_license.test.js.snap | 4 +- .../request_trial_extension.test.js.snap | 8 +- .../revert_to_basic.test.js.snap | 6 +- .../__snapshots__/start_trial.test.js.snap | 8 +- .../upload_license.test.tsx.snap | 1399 ++- .../pipeline_list/pipelines_table.test.js | 2 +- .../upgrade_failure.test.js.snap | 195 +- .../entity_control/entity_control.tsx | 3 +- .../__snapshots__/no_data.test.js.snap | 8 +- .../collection_enabled.test.js.snap | 48 +- .../collection_interval.test.js.snap | 116 +- .../__snapshots__/reason_found.test.js.snap | 2 +- .../__snapshots__/summary_status.test.js.snap | 10 - .../remote_cluster_form.test.js.snap | 113 +- .../report_info_button.test.tsx.snap | 1272 ++- .../job_create/navigation/navigation.js | 2 +- .../overwritten_session_page.test.tsx.snap | 54 +- .../role_combo_box/role_combo_box.test.tsx | 37 +- .../cypress/tasks/create_new_case.ts | 2 +- .../__snapshots__/index.test.tsx.snap | 6 + .../note_card_body.test.tsx.snap | 6 + .../open_timeline_modal/index.test.tsx | 11 +- .../components/timeline/body/index.test.tsx | 2 +- .../timeline/selectable_timeline/index.tsx | 56 +- .../client_integration/policy_add.test.ts | 10 + .../public/custom_time_range_action.test.ts | 1 - .../public/custom_time_range_badge.test.ts | 1 - .../fingerprint_col.test.tsx.snap | 4 +- .../uptime_date_picker.test.tsx.snap | 12 +- .../__snapshots__/license_info.test.tsx.snap | 2 +- .../__snapshots__/ml_flyout.test.tsx.snap | 16 +- .../ml_integerations.test.tsx.snap | 7 +- .../__snapshots__/ml_job_link.test.tsx.snap | 2 +- .../__snapshots__/ml_manage_job.test.tsx.snap | 7 +- .../location_status_tags.test.tsx.snap | 6 +- .../location_missing.test.tsx.snap | 5 +- .../__snapshots__/empty_state.test.tsx.snap | 285 +- .../filter_popover.test.tsx.snap | 7 +- .../__tests__/filter_popover.test.tsx | 13 +- .../filter_status_button.test.tsx.snap | 4 +- .../__snapshots__/monitor_list.test.tsx.snap | 19 +- .../__snapshots__/status_filter.test.tsx.snap | 12 +- .../__snapshots__/page_header.test.tsx.snap | 41 +- .../threshold_watch_edit.tsx | 2 +- .../functional/apps/maps/add_layer_panel.js | 5 +- .../functional/page_objects/graph_page.ts | 3 + yarn.lock | 137 +- 131 files changed, 12036 insertions(+), 8372 deletions(-) diff --git a/package.json b/package.json index df35e5901159b..c79c2a2f3e33a 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "@elastic/datemath": "5.0.3", "@elastic/elasticsearch": "7.9.0-rc.2", "@elastic/ems-client": "7.9.3", - "@elastic/eui": "26.3.1", + "@elastic/eui": "27.4.0", "@elastic/filesaver": "1.1.2", "@elastic/good": "8.1.1-kibana2", "@elastic/numeral": "^2.5.0", diff --git a/packages/kbn-ui-shared-deps/package.json b/packages/kbn-ui-shared-deps/package.json index ae14777e8b44a..a37281cb2263f 100644 --- a/packages/kbn-ui-shared-deps/package.json +++ b/packages/kbn-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "19.8.1", - "@elastic/eui": "26.3.1", + "@elastic/eui": "27.4.0", "@elastic/numeral": "^2.5.0", "@kbn/i18n": "1.0.0", "@kbn/monaco": "1.0.0", diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 9ecbc055e3320..72d62730fa698 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -378,7 +378,9 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
-
-
- -
-
-
    -
  • - -
  • -
-
+ + + +
+
-
- - -
- } - onActivation={[Function]} - onDeactivation={[Function]} - persistentFocus={false} - > - - +
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + sideCar={ + Object { + "assignMedium": [Function], + "assignSyncMedium": [Function], + "options": Object { + "async": true, + "ssr": false, + }, + "read": [Function], + "useMedium": [Function], + } + } + > + -
- + + Custom link + + + + +
-
-
-
+
- +
+ + Home + + + + +
-
-
- -
-
-
-
+ + +
+ -
-
-
-
-
-
- -
-
-
-
-
-
    -
  • - - - discover - - -
  • - visualize + recent 1
  • @@ -1442,16 +1364,18 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` class="euiListGroupItem euiListGroupItem--small euiListGroupItem--subdued euiListGroupItem-isClickable" > - dashboard + recent 2 @@ -1461,2983 +1385,3573 @@ exports[`CollapsibleNav renders links grouped by category 1`] = `
+
- -
-
-
-
+ + +
+
+
- + + visualize + + + +
  • + + + dashboard + + +
  • + +
    -
    -
    - +
    +
    +
    +
    -

    - Security -

    +
  • + + + metrics + + +
  • +
  • + + + logs + + +
  • +
    - - +
    +
    -
    -
    + +
    +
    +
    - + + siem + + + + +
    -
    -
    - -
    -
    -
    -
    + + +
    +
    +
    - + + monitoring + + + + +
    -
    -
    - + + canvas + + + + +
    -
    -
    -
      -
    • - -
    • -
    +
    + + Dock navigation + + + + +
    -
    - - -
    - } - onActivation={[Function]} - onDeactivation={[Function]} - persistentFocus={false} - /> - -
    - -
    - - -
    -
    - -
    - -
    + - - - -
    -
    - - -
    - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - onToggle={[Function]} - paddingSize="none" - > -
    -
    -
    +
    +
    +
    - -
    +
      - -

      - Recently viewed -

      -
      -
    -
    +
    + + Home + + + + +
    - - - -
    -
    - -
    +
    - -
    + + +
    +
    +
    +
    +
    -
  • - - + + recent 1 + + +
  • +
  • + - recent 2 - - -
  • - - - + + recent 2 + + + + +
    +
    +
    - - - - -
    -
    - -
    -
    - -
    - - - - - - - -

    - Kibana -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-kibana" - id="mockId" - initialIsOpen={true} - onToggle={[Function]} - paddingSize="none" - > -
    -
    - -
    -
    - -
    + + +
    -
    - + +
    - -
    - - - - - - - - - - -

    - Observability -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-observability" - id="mockId" - initialIsOpen={true} - onToggle={[Function]} - paddingSize="none" - > -
    -
    - -
    -
    - -
    + + +
    -
    - +
    - - + +
    +
    - - - -
    -
    - - - - - - - -

    - Security -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-security" - id="mockId" - initialIsOpen={true} - onToggle={[Function]} - paddingSize="none" - > -
    -
    - -
    -
    - -
    + + +
    -
    - +
    - - + +
    +
    - - - -
    -
    - - - - -

    - Management -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-management" - id="mockId" - initialIsOpen={true} - onToggle={[Function]} - paddingSize="none" - > -
    -
    - -
    -
    - -
    + + +
    -
    - +
    - - + +
    +
    - - - -
    -
    - -
    -
    - - - -
    -
    -
    - - -
    -
    - -
      - -
      + canvas + + + +
    +
    +
    +
    +
    +
      +
    • + , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lockOpen" - label="Dock navigation" - onClick={[Function]} - size="xs" + +
    • +
    +
    +
    + + + + + } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + > + + - -
    - -
    - - - - - -`; - -exports[`CollapsibleNav renders the default nav 1`] = ` - - - -`; - -exports[`CollapsibleNav renders the default nav 2`] = ` - - - - - - - -
    - -
    -
    -
    - - - -
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
      +
    • + +
    • +
    +
    +
    +
    + + +
    + } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + /> + + + +
    - -
    +
    + +
    +
    + + + +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    + + + +
    +
    +
    +
    +
    + + + + +

    + Recently viewed +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" + initialIsOpen={true} + onToggle={[Function]} + paddingSize="none" + > +
    +
    + +
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    - - - -
    -
    -
    -
    - - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - onToggle={[Function]} - paddingSize="none" - > -
    -
    -
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    + - + + + + + +

    + Observability +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-observability" + id="mockId" + initialIsOpen={true} + onToggle={[Function]} + paddingSize="none" >
    - - - +
    + +
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    + +
    + - + + + + + +

    + Security +

    +
    +
    +
    + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-security" + id="mockId" + initialIsOpen={true} + onToggle={[Function]} + paddingSize="none" >
    - -
    + +
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    + +
    + + +

    - Recently viewed + Management

    -
    -
    - - - - - -
    - -
    -
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-management" + id="mockId" + initialIsOpen={true} + onToggle={[Function]} + paddingSize="none" >
    - -
    - + +
    + + + + +
    + +
    + +

    + Management +

    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    -

    - No recently viewed items -

    +
    + + + +
    - -
    - +
    + +
    -
    -
    -
    -
    - - - - -
    -
    - -
    - - -
    -
    + + - -
      - -
      - - Dock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lockOpen" - label="Dock navigation" - onClick={[Function]} - size="xs" + -
    • -
    • + +
    +
    +
    +
    +
    + + +
    +
    + +
      + +
      + + Dock navigation + + , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lockOpen" + label="Dock navigation" + onClick={[Function]} + size="xs" > - Dock navigation - - - - -
    -
    -
    +
  • + +
  • + + + +
    +
    + + - - - -
    - - - - - - - -
    + + +
    + + + + close + + + + + + + + +
    + +
    + + - + > + + + + + + + + + +
    @@ -4446,7 +4960,242 @@ exports[`CollapsibleNav renders the default nav 2`] = ` `; -exports[`CollapsibleNav renders the default nav 3`] = ` +exports[`CollapsibleNav renders the default nav 1`] = ` + + + +`; + +exports[`CollapsibleNav renders the default nav 2`] = ` @@ -4682,12 +5431,16 @@ exports[`CollapsibleNav renders the default nav 3`] = ` event="keydown" handler={[Function]} /> + - -
    -
    -
    - +
    +
    +
    -
    -
    + -
    - -
    -
    -
    -
    -
    -
    -
    -

    - No recently viewed items -

    -
    -
    -
    -
    -
    -
    -
    -
    -
    -
    + + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" id="mockId" + initialIsOpen={true} + onToggle={[Function]} + paddingSize="none" >
    -
      -
    • - -
    • -
    -
    -
    -
    - - -
    - } - onActivation={[Function]} - onDeactivation={[Function]} - persistentFocus={false} - > - - -
    + + +
    + + + + close + + + + + + + + +
    + +
    - -
    + - -
    - - - - , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lock" + label="Undock navigation" + onClick={[Function]} + size="xs" + > +
  • + +
  • + + + +
    +
    + + + + + + - - close - - - - - - - - -
    - + + + +
    + + + + close + + + + + + + + +
    + +
    + + +
    diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index ce56b19f82cd0..a1920154d9f71 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -9016,953 +9016,264 @@ exports[`Header renders 3`] = ` onTouchEnd={[Function]} onTouchStart={[Function]} > - -
    -
    -
    - - -
    + lockProps={ + Object { + "allowPinchZoom": undefined, + "enabled": false, + "inert": undefined, + "shards": undefined, + "sideCar": [Function], + } } - onActivation={[Function]} - onDeactivation={[Function]} + noFocusGuards={false} persistentFocus={false} + returnFocus={true} + sideCar={[Function]} > - - -
    +
    - -
    + + +
    +
    +
    - - - -
    -
    - -
    - - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - onToggle={[Function]} - paddingSize="none" - > -
    -
    - -
    -
    - -
    -
    -
    -
    -
    -
    -
    -
    - -
    -
    - -
    - -
    +
    + + -
    + + +

    + Recently viewed +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" + data-test-subj="collapsibleNavGroup-recentlyViewed" + id="mockId" + initialIsOpen={true} + onToggle={[Function]} + paddingSize="none" > - -
      - -
    • + +
      + + + - -
    • -
      -
    -
    -
    -
    - - - +
    + +

    + Recently viewed +

    +
    +
    +
    + + + + + +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    + + + + +
    +
    +
    -
    - -
      - -
      - - Undock navigation - - , - } - } - color="subdued" - data-test-subj="collapsible-nav-lock" - iconType="lock" - label="Undock navigation" - onClick={[Function]} - size="xs" + -
    • - +
    • + +
    +
    +
    +
    + + + +
    +
    + +
      + +
      + + Undock navigation + + , + } + } + color="subdued" + data-test-subj="collapsible-nav-lock" + iconType="lock" + label="Undock navigation" + onClick={[Function]} + size="xs" > - Undock navigation - - - - -
    -
    -
    +
  • + +
  • + + + +
    + +
    +
    - - - -
    - - - - - - - -
    - + + + +
    + + + + close + + + + + + + + +
    + +
    + + +
    diff --git a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap index b17e7d0fec773..fb00ddc38c6dc 100644 --- a/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap +++ b/src/core/public/overlays/modal/__snapshots__/modal_service.test.tsx.snap @@ -31,7 +31,7 @@ Array [ ] `; -exports[`ModalService openConfirm() renders a mountpoint confirm message 2`] = `"
    Modal content
    "`; +exports[`ModalService openConfirm() renders a mountpoint confirm message 2`] = `"
    Modal content
    "`; exports[`ModalService openConfirm() renders a string confirm message 1`] = ` Array [ @@ -53,7 +53,7 @@ Array [ ] `; -exports[`ModalService openConfirm() renders a string confirm message 2`] = `"

    Some message

    "`; +exports[`ModalService openConfirm() renders a string confirm message 2`] = `"

    Some message

    "`; exports[`ModalService openConfirm() with a currently active confirm replaces the current confirm with the new one 1`] = ` Array [ diff --git a/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx b/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx index 72992c190e8ae..5b33b0e0ea120 100644 --- a/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/field/field.test.tsx @@ -25,7 +25,6 @@ import { FieldSetting } from '../../types'; import { UiSettingsType, StringValidation } from '../../../../../../core/public'; import { notificationServiceMock, docLinksServiceMock } from '../../../../../../core/public/mocks'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { Field, getEditableValue } from './field'; diff --git a/src/plugins/advanced_settings/public/management_app/components/form/form.test.tsx b/src/plugins/advanced_settings/public/management_app/components/form/form.test.tsx index 0e942665b23a9..e42432d0bc319 100644 --- a/src/plugins/advanced_settings/public/management_app/components/form/form.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/form/form.test.tsx @@ -21,7 +21,6 @@ import React from 'react'; import { shallowWithI18nProvider, mountWithI18nProvider } from 'test_utils/enzyme_helpers'; import { UiSettingsType } from '../../../../../../core/public'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { notificationServiceMock } from '../../../../../../core/public/mocks'; diff --git a/src/plugins/advanced_settings/public/management_app/components/search/search.test.tsx b/src/plugins/advanced_settings/public/management_app/components/search/search.test.tsx index 0e3fbb3cf97fa..01f54cce60319 100644 --- a/src/plugins/advanced_settings/public/management_app/components/search/search.test.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/search/search.test.tsx @@ -20,7 +20,6 @@ import React from 'react'; import { shallowWithI18nProvider, mountWithI18nProvider } from 'test_utils/enzyme_helpers'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { Query } from '@elastic/eui'; diff --git a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap index 698c124d2d805..201c6e83a4a44 100644 --- a/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -671,35 +671,54 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` iconType="plusInCircle" size="s" > - + + +
    + + + Create new + + + + +

    diff --git a/src/plugins/dashboard/public/application/dashboard_empty_screen.test.tsx b/src/plugins/dashboard/public/application/dashboard_empty_screen.test.tsx index 933475d354cfa..0a49e524d3350 100644 --- a/src/plugins/dashboard/public/application/dashboard_empty_screen.test.tsx +++ b/src/plugins/dashboard/public/application/dashboard_empty_screen.test.tsx @@ -19,7 +19,6 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { DashboardEmptyScreen, DashboardEmptyScreenProps } from './dashboard_empty_screen'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { coreMock } from '../../../../core/public/mocks'; diff --git a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx index 1e07c610b0ef2..60395bce678c2 100644 --- a/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx +++ b/src/plugins/dashboard/public/application/embeddable/viewport/dashboard_viewport.test.tsx @@ -17,7 +17,6 @@ * under the License. */ -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import React from 'react'; import { skip } from 'rxjs/operators'; diff --git a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx index 6eb85faeea014..24075e0a634ba 100644 --- a/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx +++ b/src/plugins/dashboard/public/application/tests/dashboard_container.test.tsx @@ -17,7 +17,6 @@ * under the License. */ -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import React from 'react'; import { mount } from 'enzyme'; diff --git a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.test.tsx b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.test.tsx index 18b1237895f79..aee8d1f4eac4d 100644 --- a/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.test.tsx +++ b/src/plugins/data/public/ui/shard_failure_modal/shard_failure_open_modal_button.test.tsx @@ -22,7 +22,6 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ShardFailureOpenModalButton } from './shard_failure_open_modal_button'; import { shardFailureRequest } from './__mocks__/shard_failure_request'; import { shardFailureResponse } from './__mocks__/shard_failure_response'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; describe('ShardFailureOpenModalButton', () => { diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.test.tsx b/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.test.tsx index 8976f8ea3c107..ab7adba193d87 100644 --- a/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.test.tsx +++ b/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.test.tsx @@ -20,7 +20,6 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ActionBar, ActionBarProps } from './action_bar'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from '../../query_parameters/constants'; diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx b/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx index 524161c77cbf8..2cd829d89f78e 100644 --- a/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx @@ -20,7 +20,6 @@ import React from 'react'; import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; import { ToolBarPagerButtons } from './tool_bar_pager_buttons'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; test('it renders ToolBarPagerButtons', () => { diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx b/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx index b201bea26503e..224e249a274cd 100644 --- a/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx +++ b/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx @@ -20,7 +20,6 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { TableHeader } from './table_header'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { SortOrder } from './helpers'; import { IndexPattern, IFieldType } from '../../../../../kibana_services'; diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message.test.tsx b/src/plugins/discover/public/application/components/context_error_message/context_error_message.test.tsx index 1c9439bc34e58..1cc8247957512 100644 --- a/src/plugins/discover/public/application/components/context_error_message/context_error_message.test.tsx +++ b/src/plugins/discover/public/application/components/context_error_message/context_error_message.test.tsx @@ -22,7 +22,6 @@ import { ReactWrapper } from 'enzyme'; import { ContextErrorMessage } from './context_error_message'; // @ts-ignore import { FAILURE_REASONS, LOADING_STATUS } from '../../angular/context/query'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; describe('loading spinner', function () { diff --git a/src/plugins/discover/public/application/components/doc/doc.test.tsx b/src/plugins/discover/public/application/components/doc/doc.test.tsx index 0bc621714c70f..c9fa551f61aca 100644 --- a/src/plugins/discover/public/application/components/doc/doc.test.tsx +++ b/src/plugins/discover/public/application/components/doc/doc.test.tsx @@ -20,7 +20,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { Doc, DocProps } from './doc'; diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx index 3710ce72533db..9115915690324 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx @@ -19,7 +19,6 @@ import React from 'react'; import { mount, shallow } from 'enzyme'; import { DocViewer } from './doc_viewer'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { getDocViewsRegistry } from '../../../kibana_services'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; diff --git a/src/plugins/discover/public/application/components/hits_counter/hits_counter.test.tsx b/src/plugins/discover/public/application/components/hits_counter/hits_counter.test.tsx index 84ad19dbddcbf..c2eb4f08cf549 100644 --- a/src/plugins/discover/public/application/components/hits_counter/hits_counter.test.tsx +++ b/src/plugins/discover/public/application/components/hits_counter/hits_counter.test.tsx @@ -20,7 +20,6 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { HitsCounter, HitsCounterProps } from './hits_counter'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; describe('hits counter', function () { diff --git a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.test.tsx b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.test.tsx index 3321ac764a05b..e996da5fe0e3c 100644 --- a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.test.tsx +++ b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.test.tsx @@ -20,7 +20,6 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { LoadingSpinner } from './loading_spinner'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; describe('loading spinner', function () { diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx index 3f12a8c0fa769..e1abbfd7657d0 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx @@ -18,7 +18,6 @@ */ import React from 'react'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore import StubIndexPattern from 'test_utils/stub_index_pattern'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_search.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_search.test.tsx index 654df5bfa9ee9..625d6833406eb 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_search.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_search.test.tsx @@ -19,7 +19,6 @@ import React, { EventHandler, MouseEvent as ReactMouseEvent } from 'react'; import { act } from 'react-dom/test-utils'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { DiscoverFieldSearch, Props } from './discover_field_search'; import { EuiButtonGroupProps, EuiPopover } from '@elastic/eui'; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.test.tsx index 24e6f5993a0a5..a1b231c2d4479 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.test.tsx @@ -24,7 +24,7 @@ import { ShallowWrapper } from 'enzyme'; import { ChangeIndexPattern } from './change_indexpattern'; import { SavedObject } from 'kibana/server'; import { DiscoverIndexPattern } from './discover_index_pattern'; -import { EuiSelectable, EuiSelectableList } from '@elastic/eui'; +import { EuiSelectable } from '@elastic/eui'; import { IIndexPattern } from 'src/plugins/data/public'; const indexPattern = { @@ -57,7 +57,7 @@ function getIndexPatternPickerList(instance: ShallowWrapper) { } function getIndexPatternPickerOptions(instance: ShallowWrapper) { - return getIndexPatternPickerList(instance).dive().find(EuiSelectableList).prop('options'); + return getIndexPatternPickerList(instance).prop('options'); } function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx index 90ade60d2073d..9572f35641d69 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx @@ -19,7 +19,6 @@ import _ from 'lodash'; import { ReactWrapper } from 'enzyme'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; // @ts-ignore import StubIndexPattern from 'test_utils/stub_index_pattern'; diff --git a/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button.test.tsx b/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button.test.tsx index bf417f9f1890b..fdb0ff973dcf0 100644 --- a/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button.test.tsx +++ b/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button.test.tsx @@ -20,8 +20,6 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { SkipBottomButton, SkipBottomButtonProps } from './skip_bottom_button'; -// @ts-ignore -import { findTestSubject } from '@elastic/eui/lib/test'; describe('Skip to Bottom Button', function () { let props: SkipBottomButtonProps; diff --git a/src/plugins/discover/public/application/components/table/table.test.tsx b/src/plugins/discover/public/application/components/table/table.test.tsx index 29659b3969365..5b840a25d8beb 100644 --- a/src/plugins/discover/public/application/components/table/table.test.tsx +++ b/src/plugins/discover/public/application/components/table/table.test.tsx @@ -18,7 +18,6 @@ */ import React from 'react'; import { mount } from 'enzyme'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { DocViewTable } from './table'; import { indexPatterns, IndexPattern } from '../../../../../data/public'; diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx b/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx index 964f94ca9d9b2..a4c10e749d868 100644 --- a/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx +++ b/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx @@ -21,7 +21,6 @@ import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { TimechartHeader, TimechartHeaderProps } from './timechart_header'; import { EuiIconTip } from '@elastic/eui'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; describe('timechart header', function () { diff --git a/src/plugins/embeddable/public/lib/embeddables/embeddable_root.test.tsx b/src/plugins/embeddable/public/lib/embeddables/embeddable_root.test.tsx index 131069909dd2a..cb900884fde97 100644 --- a/src/plugins/embeddable/public/lib/embeddables/embeddable_root.test.tsx +++ b/src/plugins/embeddable/public/lib/embeddables/embeddable_root.test.tsx @@ -20,7 +20,6 @@ import React from 'react'; import { HelloWorldEmbeddable } from '../../../../../../examples/embeddable_examples/public'; import { EmbeddableRoot } from './embeddable_root'; import { mount } from 'enzyme'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; test('EmbeddableRoot renders an embeddable', async () => { diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 341a51d7348b2..fcf79c1d6b211 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -21,7 +21,6 @@ import React from 'react'; import { mount } from 'enzyme'; import { nextTick } from 'test_utils/enzyme_helpers'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { I18nProvider } from '@kbn/i18n/react'; import { CONTEXT_MENU_TRIGGER } from '../triggers'; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx index 34a176400dbb9..95aee3d9cb335 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/add_panel/add_panel_flyout.test.tsx @@ -30,7 +30,6 @@ import { ContainerInput } from '../../../../containers'; import { mountWithIntl as mount } from 'test_utils/enzyme_helpers'; import { ReactWrapper } from 'enzyme'; import { coreMock } from '../../../../../../../../core/public/mocks'; -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import { embeddablePluginMock } from '../../../../../mocks'; @@ -125,7 +124,7 @@ test('selecting embeddable in "Create new ..." list calls createNewEmbeddable()' notifications={core.notifications} SavedObjectFinder={(props) => } /> - ) as ReactWrapper; + ) as ReactWrapper; const spy = jest.fn(); component.instance().createNewEmbeddable = spy; diff --git a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.test.ts b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.test.ts index 6fddcbc84faf7..dbfa55a4e0f13 100644 --- a/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.test.ts +++ b/src/plugins/embeddable/public/lib/panel/panel_header/panel_actions/customize_title/customize_panel_action.test.ts @@ -18,8 +18,6 @@ */ import { Container, isErrorEmbeddable } from '../../../..'; -// @ts-ignore -import { findTestSubject } from '@elastic/eui/lib/test'; import { nextTick } from 'test_utils/enzyme_helpers'; import { CustomizePanelTitleAction } from './customize_panel_action'; import { diff --git a/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx b/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx index e094afe528498..d12a55dd827e6 100644 --- a/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx +++ b/src/plugins/embeddable/public/tests/customize_panel_modal.test.tsx @@ -17,7 +17,6 @@ * under the License. */ -// @ts-ignore import { findTestSubject } from '@elastic/eui/lib/test'; import * as React from 'react'; import { Container, isErrorEmbeddable } from '../lib'; diff --git a/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap b/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap index 1e7b3d5c6284c..9a9e055d54d2f 100644 --- a/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap +++ b/src/plugins/home/public/application/components/tutorial/__snapshots__/saved_objects_installer.test.js.snap @@ -245,22 +245,41 @@ exports[`bulkCreate should display error message when bulkCreate request fails 1 isLoading={false} onClick={[Function]} > - + + + Load Kibana objects + + +
    + +
    @@ -565,22 +584,41 @@ exports[`bulkCreate should display success message when bulkCreate is successful isLoading={false} onClick={[Function]} > - + + + Load Kibana objects + + + + + diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/__snapshots__/header.test.tsx.snap b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/__snapshots__/header.test.tsx.snap index 6261ea2c90793..5218ebd1b4ad4 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/__snapshots__/header.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/__snapshots__/header.test.tsx.snap @@ -33,7 +33,7 @@ exports[`Header should render normally 1`] = ` type="button" > diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index a1653c5289255..992a2fcf0c89d 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { ButtonIconSide } from '@elastic/eui'; +import { EuiButtonProps } from '@elastic/eui'; export type TopNavMenuAction = (anchorElement: HTMLElement) => void; @@ -32,7 +32,7 @@ export interface TopNavMenuData { tooltip?: string | (() => string | undefined); emphasize?: boolean; iconType?: string; - iconSide?: ButtonIconSide; + iconSide?: EuiButtonProps['iconSide']; } export interface RegisteredTopNavMenuData extends TopNavMenuData { diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/header.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/header.test.tsx.snap index d56776c2be9d7..f5c2d3efd56f7 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/header.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/object_view/components/__snapshots__/header.test.tsx.snap @@ -58,45 +58,63 @@ exports[`Intro component renders correctly 1`] = ` iconType="eye" size="s" > - - -
    My Canvas Workpad
    " `; exports[`Canvas Shareable Workpad API Placed successfully with height specified 1`] = `"
    "`; @@ -21,7 +21,7 @@ exports[`Canvas Shareable Workpad API Placed successfully with height specified
    markdown mock
    markdown mock
    My Canvas Workpad
    " +
    markdown mock
    My Canvas Workpad
    " `; exports[`Canvas Shareable Workpad API Placed successfully with page specified 1`] = `"
    "`; @@ -33,7 +33,7 @@ exports[`Canvas Shareable Workpad API Placed successfully with page specified 2`
    markdown mock
    markdown mock
    My Canvas Workpad
    " +
    markdown mock
    My Canvas Workpad
    " `; exports[`Canvas Shareable Workpad API Placed successfully with width and height specified 1`] = `"
    "`; @@ -45,7 +45,7 @@ exports[`Canvas Shareable Workpad API Placed successfully with width and height
    markdown mock
    markdown mock
    My Canvas Workpad
    " +
    markdown mock
    My Canvas Workpad
    " `; exports[`Canvas Shareable Workpad API Placed successfully with width specified 1`] = `"
    "`; @@ -57,5 +57,5 @@ exports[`Canvas Shareable Workpad API Placed successfully with width specified 2
    markdown mock
    markdown mock
    My Canvas Workpad
    " +
    markdown mock
    My Canvas Workpad
    " `; diff --git a/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot index 81e75ff5ee0d9..e0970b57ed971 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot +++ b/x-pack/plugins/canvas/shareable_runtime/components/__stories__/__snapshots__/canvas.stories.storyshot @@ -1398,7 +1398,7 @@ exports[`Storyshots shareables/Canvas component 1`] = ` type="button" > App renders properly 1`] = `
    markdown mock
    markdown mock
    My Canvas Workpad
    " +
    markdown mock
    My Canvas Workpad
    " `; diff --git a/x-pack/plugins/canvas/shareable_runtime/components/footer/__stories__/__snapshots__/footer.stories.storyshot b/x-pack/plugins/canvas/shareable_runtime/components/footer/__stories__/__snapshots__/footer.stories.storyshot index 6ff39b723a49a..2d3a9c460272c 100644 --- a/x-pack/plugins/canvas/shareable_runtime/components/footer/__stories__/__snapshots__/footer.stories.storyshot +++ b/x-pack/plugins/canvas/shareable_runtime/components/footer/__stories__/__snapshots__/footer.stories.storyshot @@ -1351,7 +1351,7 @@ exports[`Storyshots shareables/Footer contextual: austin 1`] = ` type="button" > can navigate Autoplay Settings 2`] = ` type="submit" > - - -
    + +
    + + - + > + + + + + + + + + + @@ -1076,21 +1469,31 @@ exports[`UploadLicense should display a modal when license requires acknowledgem onClick={[Function]} rel="noreferrer" > - - - Cancel - + + Cancel + + - +
    @@ -1107,28 +1510,48 @@ exports[`UploadLicense should display a modal when license requires acknowledgem isLoading={false} onClick={[Function]} > - + + + Upload + + + + + +
    @@ -1734,21 +2157,31 @@ exports[`UploadLicense should display an error when ES says license is expired 1 onClick={[Function]} rel="noreferrer" > - - - Cancel - + + Cancel + + -
    + @@ -1765,28 +2198,48 @@ exports[`UploadLicense should display an error when ES says license is expired 1 isLoading={false} onClick={[Function]} > - + + + Upload + + +
    + + + @@ -2392,21 +2845,31 @@ exports[`UploadLicense should display an error when ES says license is invalid 1 onClick={[Function]} rel="noreferrer" > - - - Cancel - + + Cancel + + -
    + @@ -2423,28 +2886,48 @@ exports[`UploadLicense should display an error when ES says license is invalid 1 isLoading={false} onClick={[Function]} > - + + + Upload + + +
    + + + @@ -3050,21 +3533,31 @@ exports[`UploadLicense should display an error when submitting invalid JSON 1`] onClick={[Function]} rel="noreferrer" > - - - Cancel - + + Cancel + + -
    + @@ -3081,28 +3574,48 @@ exports[`UploadLicense should display an error when submitting invalid JSON 1`] isLoading={false} onClick={[Function]} > - + + + Upload + + +
    + + + @@ -3708,21 +4221,31 @@ exports[`UploadLicense should display error when ES returns error 1`] = ` onClick={[Function]} rel="noreferrer" > - - - Cancel - + + Cancel + + -
    + @@ -3739,28 +4262,48 @@ exports[`UploadLicense should display error when ES returns error 1`] = ` isLoading={false} onClick={[Function]} > - + + + Upload + + +
    + + + diff --git a/x-pack/plugins/logstash/public/application/components/pipeline_list/pipelines_table.test.js b/x-pack/plugins/logstash/public/application/components/pipeline_list/pipelines_table.test.js index 2c25ae69ae722..6c2f0d02801c4 100644 --- a/x-pack/plugins/logstash/public/application/components/pipeline_list/pipelines_table.test.js +++ b/x-pack/plugins/logstash/public/application/components/pipeline_list/pipelines_table.test.js @@ -45,7 +45,7 @@ describe('PipelinesTable component', () => { it('calls clone when cloned button clicked', () => { props.pipelines = [{ id: 'testPipeline', isCentrallyManaged: true }]; const wrapper = mountWithIntl(); - wrapper.find('[iconType="copy"]').simulate('click'); + wrapper.find('[iconType="copy"]').first().simulate('click'); expect(clonePipeline).toHaveBeenCalled(); }); diff --git a/x-pack/plugins/logstash/public/application/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap b/x-pack/plugins/logstash/public/application/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap index 5f54513c427dd..d0f1bed8e8e9e 100644 --- a/x-pack/plugins/logstash/public/application/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap +++ b/x-pack/plugins/logstash/public/application/components/upgrade_failure/__snapshots__/upgrade_failure.test.js.snap @@ -265,21 +265,38 @@ exports[`UpgradeFailure component passes expected text for new pipeline 1`] = ` fill={true} onClick={[MockFunction]} > - + + + Try again + + + + + @@ -298,21 +315,31 @@ exports[`UpgradeFailure component passes expected text for new pipeline 1`] = ` onClick={[MockFunction]} type="button" > - - - Go back - + + Go back + + -
    + @@ -594,21 +621,38 @@ exports[`UpgradeFailure component passes expected text for not manual upgrade 1` fill={true} onClick={[MockFunction]} > - + + + Upgrade + + + + + @@ -627,21 +671,31 @@ exports[`UpgradeFailure component passes expected text for not manual upgrade 1` onClick={[MockFunction]} type="button" > - - - Go back - + + Go back + + -
    + @@ -923,21 +977,38 @@ exports[`UpgradeFailure component passes expected text for not new pipeline 1`] fill={true} onClick={[MockFunction]} > - + + + Try again + + + + + @@ -956,21 +1027,31 @@ exports[`UpgradeFailure component passes expected text for not new pipeline 1`] onClick={[MockFunction]} type="button" > - - - Go back - + + Go back + + -
    + diff --git a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx index c144525699d81..93bb62fa1fc58 100644 --- a/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx +++ b/x-pack/plugins/ml/public/application/timeseriesexplorer/components/entity_control/entity_control.tsx @@ -16,7 +16,6 @@ import { EuiFormRow, EuiToolTip, } from '@elastic/eui'; -import { EuiSelectableOption } from '@elastic/eui/src/components/selectable/selectable_option'; export interface Entity { fieldName: string; @@ -113,7 +112,7 @@ export class EntityControl extends Component { + renderOption = (option: EuiComboBoxOptionOption) => { const { label } = option; return label === EMPTY_FIELD_VALUE_LABEL ? {label} : label; }; diff --git a/x-pack/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap index 72f6e2b01c129..c06f71e79e766 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/__tests__/__snapshots__/no_data.test.js.snap @@ -52,7 +52,7 @@ exports[`NoData should show a default message if reason is unknown 1`] = ` type="button" > - + + + Turn on monitoring + + + + + + diff --git a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__tests__/__snapshots__/collection_interval.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__tests__/__snapshots__/collection_interval.test.js.snap index 49cbe092e0e20..782be3a073016 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__tests__/__snapshots__/collection_interval.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/explanations/collection_interval/__tests__/__snapshots__/collection_interval.test.js.snap @@ -430,37 +430,59 @@ exports[`ExplainCollectionInterval collection interval setting updates should sh onClick={[Function]} type="button" > - + + + + + + Turn on monitoring + + + + + + @@ -728,28 +750,48 @@ exports[`ExplainCollectionInterval should explain about xpack.monitoring.collect onClick={[Function]} type="button" > - + + + Turn on monitoring + + + + + + diff --git a/x-pack/plugins/monitoring/public/components/no_data/reasons/__tests__/__snapshots__/reason_found.test.js.snap b/x-pack/plugins/monitoring/public/components/no_data/reasons/__tests__/__snapshots__/reason_found.test.js.snap index 898be82b139d1..e7b88e23c5f68 100644 --- a/x-pack/plugins/monitoring/public/components/no_data/reasons/__tests__/__snapshots__/reason_found.test.js.snap +++ b/x-pack/plugins/monitoring/public/components/no_data/reasons/__tests__/__snapshots__/reason_found.test.js.snap @@ -61,7 +61,7 @@ Array [ type="button" >  Yellow

    -

    - Status [object Object] -

     Green

    -

    - Status [object Object] -

    - + +
    + + + + Save + + + + + +
    @@ -1326,21 +1348,31 @@ exports[`RemoteClusterForm proxy mode renders correct connection settings when u onClick={[Function]} type="button" > - - - Show request - + + Show request + + - +
    @@ -1743,11 +1775,10 @@ Array [ type="button" > - -
    +
    + + - + > + + + + + + + + + +
    @@ -449,7 +717,9 @@ Array [
    @@ -844,27 +897,44 @@ exports[`EmptyState component doesn't render child components when count is fals color="primary" href="/app/uptime#/settings" > - - - - - Update index pattern settings - - - - + + + Update index pattern settings + + +
    + + + @@ -1256,27 +1326,45 @@ exports[`EmptyState component notifies when index does not exist 1`] = ` fill={true} href="/app/home#/tutorial/uptimeMonitors" > - - - - - View setup instructions - - - - + + + View setup instructions + + +
    + + + @@ -1288,27 +1376,44 @@ exports[`EmptyState component notifies when index does not exist 1`] = ` color="primary" href="/app/uptime#/settings" > - - - - - Update index pattern settings - - - - + + + Update index pattern settings + + +
    + + + diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap b/x-pack/plugins/uptime/public/components/overview/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap index 2677fd828c957..63ba8bcf9d26a 100644 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap +++ b/x-pack/plugins/uptime/public/components/overview/filter_group/__tests__/__snapshots__/filter_popover.test.tsx.snap @@ -94,15 +94,14 @@ exports[`FilterPopover component returns selected items on popover close 1`] = ` class="euiPopover__anchor" >