From 4721b3211a5e674ff61f92017a380a2c2fb60a05 Mon Sep 17 00:00:00 2001 From: John Schulz Date: Thu, 12 Nov 2020 05:47:43 -0500 Subject: [PATCH 1/9] [Ingest Manger] Move asset getters out of registry (#83214) ## Summary Packages/Archives aren't limited to the registry any longer. Continue moving file- & cache-related functions from services/registry to services/archive. Move `getAsset` and `pathParts` to archive/index. The behavior is the same for now, but it's more accurate to separate these from registry namespace. Registry has `fetch*` and other functions for dealing with the online service. --- .../server/services/epm/archive/index.ts | 40 +++++++++++++++- .../services/epm/elasticsearch/ilm/install.ts | 8 ++-- .../elasticsearch/ingest_pipeline/install.ts | 11 +++-- .../epm/elasticsearch/template/install.ts | 14 +++--- .../epm/elasticsearch/transform/common.ts | 6 +-- .../epm/elasticsearch/transform/install.ts | 4 +- .../services/epm/kibana/assets/install.ts | 8 ++-- .../server/services/epm/packages/assets.ts | 4 +- .../services/epm/registry/index.test.ts | 5 +- .../server/services/epm/registry/index.ts | 48 +++---------------- 10 files changed, 75 insertions(+), 73 deletions(-) diff --git a/x-pack/plugins/fleet/server/services/epm/archive/index.ts b/x-pack/plugins/fleet/server/services/epm/archive/index.ts index 28f635e9412ae..810740d697fcb 100644 --- a/x-pack/plugins/fleet/server/services/epm/archive/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/archive/index.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { ArchivePackage } from '../../../../common/types'; +import { ArchivePackage, AssetParts } from '../../../../common/types'; import { PackageInvalidArchiveError, PackageUnsupportedMediaTypeError } from '../../../errors'; import { + cacheGet, cacheSet, cacheDelete, getArchiveFilelist, @@ -100,3 +101,40 @@ export const deletePackageCache = (name: string, version: string) => { // this has been populated in unpackArchiveToCache() paths?.forEach((path) => cacheDelete(path)); }; + +export function getPathParts(path: string): AssetParts { + let dataset; + + let [pkgkey, service, type, file] = path.split('/'); + + // if it's a data stream + if (service === 'data_stream') { + // save the dataset name + dataset = type; + // drop the `data_stream/dataset-name` portion & re-parse + [pkgkey, service, type, file] = path.replace(`data_stream/${dataset}/`, '').split('/'); + } + + // This is to cover for the fields.yml files inside the "fields" directory + if (file === undefined) { + file = type; + type = 'fields'; + service = ''; + } + + return { + pkgkey, + service, + type, + file, + dataset, + path, + } as AssetParts; +} + +export function getAsset(key: string) { + const buffer = cacheGet(key); + if (buffer === undefined) throw new Error(`Cannot find asset ${key}`); + + return buffer; +} diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts index c5253e4902cab..46c0729a650d0 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ilm/install.ts @@ -5,15 +5,15 @@ */ import { CallESAsCurrentUser, ElasticsearchAssetType } from '../../../../types'; -import * as Registry from '../../registry'; +import { getAsset, getPathParts } from '../../archive'; export async function installILMPolicy(paths: string[], callCluster: CallESAsCurrentUser) { const ilmPaths = paths.filter((path) => isILMPolicy(path)); if (!ilmPaths.length) return; await Promise.all( ilmPaths.map(async (path) => { - const body = Registry.getAsset(path).toString('utf-8'); - const { file } = Registry.pathParts(path); + const body = getAsset(path).toString('utf-8'); + const { file } = getPathParts(path); const name = file.substr(0, file.lastIndexOf('.')); try { await callCluster('transport.request', { @@ -28,7 +28,7 @@ export async function installILMPolicy(paths: string[], callCluster: CallESAsCur ); } const isILMPolicy = (path: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return pathParts.type === ElasticsearchAssetType.ilmPolicy; }; export async function policyExists( diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts index 58abdeb0d443d..c5c9e8ac2c01b 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/ingest_pipeline/install.ts @@ -11,7 +11,8 @@ import { ElasticsearchAssetType, InstallablePackage, } from '../../../../types'; -import * as Registry from '../../registry'; +import { ArchiveEntry } from '../../registry'; +import { getAsset, getPathParts } from '../../archive'; import { CallESAsCurrentUser } from '../../../../types'; import { saveInstalledEsRefs } from '../../packages/install'; import { getInstallationObject } from '../../packages'; @@ -127,7 +128,7 @@ export async function installPipelinesForDataStream({ dataStream, packageVersion: pkgVersion, }); - const content = Registry.getAsset(path).toString('utf-8'); + const content = getAsset(path).toString('utf-8'); pipelines.push({ name, nameForInstallation, @@ -192,10 +193,10 @@ async function installPipeline({ return { id: pipeline.nameForInstallation, type: ElasticsearchAssetType.ingestPipeline }; } -const isDirectory = ({ path }: Registry.ArchiveEntry) => path.endsWith('/'); +const isDirectory = ({ path }: ArchiveEntry) => path.endsWith('/'); const isDataStreamPipeline = (path: string, dataStreamDataset: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return ( !isDirectory({ path }) && pathParts.type === ElasticsearchAssetType.ingestPipeline && @@ -204,7 +205,7 @@ const isDataStreamPipeline = (path: string, dataStreamDataset: string) => { ); }; const isPipeline = (path: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return pathParts.type === ElasticsearchAssetType.ingestPipeline; }; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 25d412b685904..199026da30c11 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -17,7 +17,7 @@ import { CallESAsCurrentUser } from '../../../../types'; import { Field, loadFieldsFromYaml, processFields } from '../../fields/field'; import { getPipelineNameForInstallation } from '../ingest_pipeline/install'; import { generateMappings, generateTemplateName, getTemplate } from './template'; -import * as Registry from '../../registry'; +import { getAsset, getPathParts } from '../../archive'; import { removeAssetsFromInstalledEsByType, saveInstalledEsRefs } from '../../packages/install'; export const installTemplates = async ( @@ -76,9 +76,9 @@ export const installTemplates = async ( const installPreBuiltTemplates = async (paths: string[], callCluster: CallESAsCurrentUser) => { const templatePaths = paths.filter((path) => isTemplate(path)); const templateInstallPromises = templatePaths.map(async (path) => { - const { file } = Registry.pathParts(path); + const { file } = getPathParts(path); const templateName = file.substr(0, file.lastIndexOf('.')); - const content = JSON.parse(Registry.getAsset(path).toString('utf8')); + const content = JSON.parse(getAsset(path).toString('utf8')); let templateAPIPath = '_template'; // v2 index templates need to be installed through the new API endpoint. @@ -121,9 +121,9 @@ const installPreBuiltComponentTemplates = async ( ) => { const templatePaths = paths.filter((path) => isComponentTemplate(path)); const templateInstallPromises = templatePaths.map(async (path) => { - const { file } = Registry.pathParts(path); + const { file } = getPathParts(path); const templateName = file.substr(0, file.lastIndexOf('.')); - const content = JSON.parse(Registry.getAsset(path).toString('utf8')); + const content = JSON.parse(getAsset(path).toString('utf8')); const callClusterParams: { method: string; @@ -151,12 +151,12 @@ const installPreBuiltComponentTemplates = async ( }; const isTemplate = (path: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return pathParts.type === ElasticsearchAssetType.indexTemplate; }; const isComponentTemplate = (path: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return pathParts.type === ElasticsearchAssetType.componentTemplate; }; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/common.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/common.ts index 46f36dba96747..764e1b51f1bca 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/common.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/common.ts @@ -4,8 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -import * as Registry from '../../registry'; - -export const getAsset = (path: string): Buffer => { - return Registry.getAsset(path); -}; +export { getAsset } from '../../archive'; diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts index 1002eedc48740..9da5e8cd0a937 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/transform/install.ts @@ -7,7 +7,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { saveInstalledEsRefs } from '../../packages/install'; -import * as Registry from '../../registry'; +import { getPathParts } from '../../archive'; import { ElasticsearchAssetType, EsAssetReference, @@ -104,7 +104,7 @@ export const installTransform = async ( }; const isTransform = (path: string) => { - const pathParts = Registry.pathParts(path); + const pathParts = getPathParts(path); return !path.endsWith('/') && pathParts.type === ElasticsearchAssetType.transform; }; diff --git a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts index e7b251ef133c5..fe93ed84b32f2 100644 --- a/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/kibana/assets/install.ts @@ -10,7 +10,7 @@ import { SavedObjectsClientContract, } from 'src/core/server'; import { PACKAGES_SAVED_OBJECT_TYPE } from '../../../../../common'; -import * as Registry from '../../registry'; +import { getAsset, getPathParts } from '../../archive'; import { AssetType, KibanaAssetType, @@ -57,7 +57,7 @@ const AssetInstallers: Record< }; export async function getKibanaAsset(key: string): Promise { - const buffer = Registry.getAsset(key); + const buffer = getAsset(key); // cache values are buffers. convert to string / JSON return JSON.parse(buffer.toString('utf8')); @@ -117,14 +117,14 @@ export async function getKibanaAssets( ): Promise> { const kibanaAssetTypes = Object.values(KibanaAssetType); const isKibanaAssetType = (path: string) => { - const parts = Registry.pathParts(path); + const parts = getPathParts(path); return parts.service === 'kibana' && (kibanaAssetTypes as string[]).includes(parts.type); }; const filteredPaths = paths .filter(isKibanaAssetType) - .map<[string, AssetParts]>((path) => [path, Registry.pathParts(path)]); + .map<[string, AssetParts]>((path) => [path, getPathParts(path)]); const assetArrays: Array> = []; for (const assetType of kibanaAssetTypes) { diff --git a/x-pack/plugins/fleet/server/services/epm/packages/assets.ts b/x-pack/plugins/fleet/server/services/epm/packages/assets.ts index 2e2090312c9ae..50d8f2f4d2fb2 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/assets.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/assets.ts @@ -6,7 +6,7 @@ import { InstallablePackage } from '../../../types'; import * as Registry from '../registry'; -import { getArchiveFilelist } from '../archive/cache'; +import { getArchiveFilelist, getAsset } from '../archive'; // paths from RegistryPackage are routes to the assets on EPR // e.g. `/package/nginx/1.2.0/data_stream/access/fields/fields.yml` @@ -59,7 +59,7 @@ export async function getAssetsData( // Gather all asset data const assets = getAssets(packageInfo, filter, datasetName); const entries: Registry.ArchiveEntry[] = assets.map((path) => { - const buffer = Registry.getAsset(path); + const buffer = getAsset(path); return { path, buffer }; }); diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.test.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.test.ts index a2d5c8147002d..1208ffdaefe4a 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.test.ts @@ -5,7 +5,8 @@ */ import { AssetParts } from '../../../types'; -import { getBufferExtractor, pathParts, splitPkgKey } from './index'; +import { getPathParts } from '../archive'; +import { getBufferExtractor, splitPkgKey } from './index'; import { untarBuffer, unzipBuffer } from './extract'; const testPaths = [ @@ -46,7 +47,7 @@ const testPaths = [ test('testPathParts', () => { for (const value of testPaths) { - expect(pathParts(value.path)).toStrictEqual(value.assetParts as AssetParts); + expect(getPathParts(value.path)).toStrictEqual(value.assetParts as AssetParts); } }); diff --git a/x-pack/plugins/fleet/server/services/epm/registry/index.ts b/x-pack/plugins/fleet/server/services/epm/registry/index.ts index 52a1894570b2a..c35e91bdf580b 100644 --- a/x-pack/plugins/fleet/server/services/epm/registry/index.ts +++ b/x-pack/plugins/fleet/server/services/epm/registry/index.ts @@ -8,7 +8,6 @@ import semver from 'semver'; import { Response } from 'node-fetch'; import { URL } from 'url'; import { - AssetParts, AssetsGroupedByServiceByType, CategoryId, CategorySummaryList, @@ -18,8 +17,12 @@ import { RegistrySearchResults, RegistrySearchResult, } from '../../../types'; -import { unpackArchiveToCache } from '../archive'; -import { cacheGet, getArchiveFilelist, setArchiveFilelist } from '../archive'; +import { + getArchiveFilelist, + getPathParts, + setArchiveFilelist, + unpackArchiveToCache, +} from '../archive'; import { fetchUrl, getResponse, getResponseStream } from './requests'; import { streamToBuffer } from './streams'; import { getRegistryUrl } from './registry_url'; @@ -146,36 +149,6 @@ export async function getRegistryPackage( return { paths, registryPackageInfo }; } -export function pathParts(path: string): AssetParts { - let dataset; - - let [pkgkey, service, type, file] = path.split('/'); - - // if it's a data stream - if (service === 'data_stream') { - // save the dataset name - dataset = type; - // drop the `data_stream/dataset-name` portion & re-parse - [pkgkey, service, type, file] = path.replace(`data_stream/${dataset}/`, '').split('/'); - } - - // This is to cover for the fields.yml files inside the "fields" directory - if (file === undefined) { - file = type; - type = 'fields'; - service = ''; - } - - return { - pkgkey, - service, - type, - file, - dataset, - path, - } as AssetParts; -} - export async function ensureCachedArchiveInfo( name: string, version: string, @@ -204,19 +177,12 @@ async function fetchArchiveBuffer( return { archiveBuffer, archivePath }; } -export function getAsset(key: string) { - const buffer = cacheGet(key); - if (buffer === undefined) throw new Error(`Cannot find asset ${key}`); - - return buffer; -} - export function groupPathsByService(paths: string[]): AssetsGroupedByServiceByType { const kibanaAssetTypes = Object.values(KibanaAssetType); // ASK: best way, if any, to avoid `any`? const assets = paths.reduce((map: any, path) => { - const parts = pathParts(path.replace(/^\/package\//, '')); + const parts = getPathParts(path.replace(/^\/package\//, '')); if (parts.service === 'kibana' && kibanaAssetTypes.includes(parts.type)) { if (!map[parts.service]) map[parts.service] = {}; if (!map[parts.service][parts.type]) map[parts.service][parts.type] = []; From b8576ed2ddf5cff3f36ab2163bde00e7ffa51d4c Mon Sep 17 00:00:00 2001 From: Anton Dosov Date: Thu, 12 Nov 2020 13:13:35 +0100 Subject: [PATCH 2/9] fix truncation issue (#83000) --- .../public/chrome/ui/header/header_breadcrumbs.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx index d52faa87cfecd..ee3311a94a202 100644 --- a/src/core/public/chrome/ui/header/header_breadcrumbs.tsx +++ b/src/core/public/chrome/ui/header/header_breadcrumbs.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { EuiHeaderBreadcrumbs } from '@elastic/eui'; +import { EuiFlexGroup, EuiHeaderBreadcrumbs } from '@elastic/eui'; import classNames from 'classnames'; import React from 'react'; import useObservable from 'react-use/lib/useObservable'; @@ -51,15 +51,14 @@ export function HeaderBreadcrumbs({ appTitle$, breadcrumbs$, breadcrumbsAppendEx ), })); - if (breadcrumbsAppendExtension) { + if (breadcrumbsAppendExtension && crumbs[crumbs.length - 1]) { const lastCrumb = crumbs[crumbs.length - 1]; lastCrumb.text = ( - <> - {lastCrumb.text} - - + +
{lastCrumb.text}
+ +
); } - return ; } From 4d346cdfc01f473dec471b2b6f18671005be6fb3 Mon Sep 17 00:00:00 2001 From: Thomas Neirynck Date: Thu, 12 Nov 2020 09:04:08 -0500 Subject: [PATCH 3/9] Add maps_oss folder to code_owners (#83204) --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 62abf281e659f..b7fb3ff04db71 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -137,6 +137,7 @@ /x-pack/test/functional/es_archives/maps/ @elastic/kibana-gis /x-pack/test/visual_regression/tests/maps/index.js @elastic/kibana-gis #CC# /src/plugins/maps_legacy/ @elastic/kibana-gis +#CC# /src/plugins/maps_oss/ @elastic/kibana-gis #CC# /x-pack/plugins/file_upload @elastic/kibana-gis #CC# /x-pack/plugins/maps_legacy_licensing @elastic/kibana-gis #CC# /src/plugins/home/server/tutorials @elastic/kibana-gis From 3a849ff1040e7e9e522a3c8297539c1b013d762a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Thu, 12 Nov 2020 15:05:08 +0100 Subject: [PATCH 4/9] [Index Management] Add an index template link to data stream details (#82592) * Add index template link to data stream details * Fixed ILM policy link and added a check for index template name after navigation Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../helpers/test_subjects.ts | 1 + .../home/data_streams_tab.helpers.ts | 20 ++++++++++++++ .../home/data_streams_tab.test.ts | 27 +++++++++++++++++++ .../data_stream_detail_panel.tsx | 16 ++++++----- 4 files changed, 58 insertions(+), 6 deletions(-) diff --git a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts index b5386dec34205..313ebefb85301 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/helpers/test_subjects.ts @@ -51,4 +51,5 @@ export type TestSubjects = | 'templateList' | 'templatesTab' | 'templateTable' + | 'title' | 'viewButton'; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index 6bf6c11a37bb4..ab796767487b5 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -30,6 +30,7 @@ export interface DataStreamsTabTestBed extends TestBed { clickDeleteActionAt: (index: number) => void; clickConfirmDelete: () => void; clickDeleteDataStreamButton: () => void; + clickDetailPanelIndexTemplateLink: () => void; }; findDeleteActionAt: (index: number) => ReactWrapper; findDeleteConfirmationModal: () => ReactWrapper; @@ -38,6 +39,7 @@ export interface DataStreamsTabTestBed extends TestBed { findEmptyPromptIndexTemplateLink: () => ReactWrapper; findDetailPanelIlmPolicyLink: () => ReactWrapper; findDetailPanelIlmPolicyName: () => ReactWrapper; + findDetailPanelIndexTemplateLink: () => ReactWrapper; } export const setup = async (overridingDependencies: any = {}): Promise => { @@ -143,6 +145,17 @@ export const setup = async (overridingDependencies: any = {}): Promise { + const { component, router, find } = testBed; + const indexTemplateLink = find('indexTemplateLink'); + + await act(async () => { + router.navigateTo(indexTemplateLink.props().href!); + }); + + component.update(); + }; + const findDetailPanel = () => { const { find } = testBed; return find('dataStreamDetailPanel'); @@ -158,6 +171,11 @@ export const setup = async (overridingDependencies: any = {}): Promise { + const { find } = testBed; + return find('indexTemplateLink'); + }; + const findDetailPanelIlmPolicyName = () => { const descriptionList = testBed.component.find(EuiDescriptionListDescription); // ilm policy is the last in the details list @@ -176,6 +194,7 @@ export const setup = async (overridingDependencies: any = {}): Promise { setLoadIndicesResponse, setLoadDataStreamsResponse, setLoadDataStreamResponse, + setLoadTemplateResponse, + setLoadTemplatesResponse, } = httpRequestsMockHelpers; setLoadIndicesResponse([ @@ -103,6 +106,10 @@ describe('Data Streams tab', () => { ]); setLoadDataStreamResponse(dataStreamForDetailPanel); + const indexTemplate = fixtures.getTemplate({ name: 'indexTemplate' }); + setLoadTemplatesResponse({ templates: [indexTemplate], legacyTemplates: [] }); + setLoadTemplateResponse(indexTemplate); + testBed = await setup({ history: createMemoryHistory() }); await act(async () => { testBed.actions.goToDataStreamsList(); @@ -244,6 +251,26 @@ describe('Data Streams tab', () => { dataStreams: ['dataStream1'], }); }); + + test('clicking index template name navigates to the index template details', async () => { + const { + actions: { clickNameAt, clickDetailPanelIndexTemplateLink }, + findDetailPanelIndexTemplateLink, + component, + find, + } = testBed; + + await clickNameAt(0); + + const indexTemplateLink = findDetailPanelIndexTemplateLink(); + expect(indexTemplateLink.text()).toBe('indexTemplate'); + + await clickDetailPanelIndexTemplateLink(); + + component.update(); + expect(find('summaryTab').exists()).toBeTruthy(); + expect(find('title').text().trim()).toBe('indexTemplate'); + }); }); }); diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index 9ec6993717435..05d7e97745b9e 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -29,9 +29,9 @@ import { useLoadDataStream } from '../../../../services/api'; import { DeleteDataStreamConfirmationModal } from '../delete_data_stream_confirmation_modal'; import { humanizeTimeStamp } from '../humanize_time_stamp'; import { useUrlGenerator } from '../../../../services/use_url_generator'; +import { getIndexListUri, getTemplateDetailsLink } from '../../../../services/routing'; import { ILM_PAGES_POLICY_EDIT, ILM_URL_GENERATOR_ID } from '../../../../constants'; import { useAppContext } from '../../../../app_context'; -import { getIndexListUri } from '../../../../..'; interface DetailsListProps { details: Array<{ @@ -207,7 +207,14 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ defaultMessage: 'The index template that configured the data stream and configures its backing indices', }), - content: indexTemplateName, + content: ( + + {indexTemplateName} + + ), }, { name: i18n.translate('xpack.idxMgmt.dataStreamDetailPanel.ilmPolicyTitle', { @@ -218,10 +225,7 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({ }), content: ilmPolicyName && ilmPolicyLink ? ( - + {ilmPolicyName} ) : ( From 0e7bcf6164c92b3a0a8bedebc3a81885624217bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20G=C3=B3mez?= Date: Thu, 12 Nov 2020 15:11:43 +0100 Subject: [PATCH 5/9] [Logs UI] Add pagination to the log stream shared component (#81193) Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/http_api/log_entries/entries.ts | 16 +- .../public/components/log_stream/index.tsx | 47 ++++- .../containers/logs/log_entries/index.ts | 18 +- .../containers/logs/log_stream/index.ts | 171 ++++++++++++++++-- .../log_entries/kibana_log_entries_adapter.ts | 25 ++- .../log_entries_domain/log_entries_domain.ts | 52 +++--- .../server/routes/log_entries/entries.ts | 41 +++-- .../server/routes/log_entries/highlights.ts | 2 +- 8 files changed, 299 insertions(+), 73 deletions(-) diff --git a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts index d38b4690fed71..48790c3faca52 100644 --- a/x-pack/plugins/infra/common/http_api/log_entries/entries.ts +++ b/x-pack/plugins/infra/common/http_api/log_entries/entries.ts @@ -99,11 +99,17 @@ export type LogEntryContext = rt.TypeOf; export type LogEntry = rt.TypeOf; export const logEntriesResponseRT = rt.type({ - data: rt.type({ - entries: rt.array(logEntryRT), - topCursor: rt.union([logEntriesCursorRT, rt.null]), - bottomCursor: rt.union([logEntriesCursorRT, rt.null]), - }), + data: rt.intersection([ + rt.type({ + entries: rt.array(logEntryRT), + topCursor: rt.union([logEntriesCursorRT, rt.null]), + bottomCursor: rt.union([logEntriesCursorRT, rt.null]), + }), + rt.partial({ + hasMoreBefore: rt.boolean, + hasMoreAfter: rt.boolean, + }), + ]), }); export type LogEntriesResponse = rt.TypeOf; diff --git a/x-pack/plugins/infra/public/components/log_stream/index.tsx b/x-pack/plugins/infra/public/components/log_stream/index.tsx index 6698018e8cc19..62a4d7ffc3d81 100644 --- a/x-pack/plugins/infra/public/components/log_stream/index.tsx +++ b/x-pack/plugins/infra/public/components/log_stream/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { noop } from 'lodash'; import useMount from 'react-use/lib/useMount'; import { euiStyled } from '../../../../observability/public'; @@ -17,6 +17,8 @@ import { useLogStream } from '../../containers/logs/log_stream'; import { ScrollableLogTextStreamView } from '../logging/log_text_stream'; +const PAGE_THRESHOLD = 2; + export interface LogStreamProps { sourceId?: string; startTimestamp: number; @@ -58,7 +60,16 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re }); // Internal state - const { loadingState, entries, fetchEntries } = useLogStream({ + const { + loadingState, + pageLoadingState, + entries, + hasMoreBefore, + hasMoreAfter, + fetchEntries, + fetchPreviousEntries, + fetchNextEntries, + } = useLogStream({ sourceId, startTimestamp, endTimestamp, @@ -70,6 +81,8 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re const isReloading = isLoadingSourceConfiguration || loadingState === 'uninitialized' || loadingState === 'loading'; + const isLoadingMore = pageLoadingState === 'loading'; + const columnConfigurations = useMemo(() => { return sourceConfiguration ? sourceConfiguration.configuration.logColumns : []; }, [sourceConfiguration]); @@ -84,13 +97,33 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re [entries] ); + const parsedHeight = typeof height === 'number' ? `${height}px` : height; + // Component lifetime useMount(() => { loadSourceConfiguration(); fetchEntries(); }); - const parsedHeight = typeof height === 'number' ? `${height}px` : height; + // Pagination handler + const handlePagination = useCallback( + ({ fromScroll, pagesBeforeStart, pagesAfterEnd }) => { + if (!fromScroll) { + return; + } + + if (isLoadingMore) { + return; + } + + if (pagesBeforeStart < PAGE_THRESHOLD) { + fetchPreviousEntries(); + } else if (pagesAfterEnd < PAGE_THRESHOLD) { + fetchNextEntries(); + } + }, + [isLoadingMore, fetchPreviousEntries, fetchNextEntries] + ); return ( @@ -101,13 +134,13 @@ Read more at https://github.com/elastic/kibana/blob/master/src/plugins/kibana_re scale="medium" wrap={false} isReloading={isReloading} - isLoadingMore={false} - hasMoreBeforeStart={false} - hasMoreAfterEnd={false} + isLoadingMore={isLoadingMore} + hasMoreBeforeStart={hasMoreBefore} + hasMoreAfterEnd={hasMoreAfter} isStreaming={false} lastLoadedTime={null} jumpToTarget={noop} - reportVisibleInterval={noop} + reportVisibleInterval={handlePagination} loadNewerItems={noop} reloadItems={fetchEntries} highlightedItem={highlight ?? null} diff --git a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts index 146746af980c9..bf4c5fbe0b13b 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_entries/index.ts @@ -367,16 +367,16 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action case Action.ReceiveNewEntries: return { ...prevState, - ...action.payload, + entries: action.payload.entries, + topCursor: action.payload.topCursor, + bottomCursor: action.payload.bottomCursor, centerCursor: getCenterCursor(action.payload.entries), lastLoadedTime: new Date(), isReloading: false, - - // Be optimistic. If any of the before/after requests comes empty, set - // the corresponding flag to `false` - hasMoreBeforeStart: true, - hasMoreAfterEnd: true, + hasMoreBeforeStart: action.payload.hasMoreBefore ?? prevState.hasMoreBeforeStart, + hasMoreAfterEnd: action.payload.hasMoreAfter ?? prevState.hasMoreAfterEnd, }; + case Action.ReceiveEntriesBefore: { const newEntries = action.payload.entries; const prevEntries = cleanDuplicateItems(prevState.entries, newEntries); @@ -385,7 +385,7 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action const update = { entries, isLoadingMore: false, - hasMoreBeforeStart: newEntries.length > 0, + hasMoreBeforeStart: action.payload.hasMoreBefore ?? prevState.hasMoreBeforeStart, // Keep the previous cursor if request comes empty, to easily extend the range. topCursor: newEntries.length > 0 ? action.payload.topCursor : prevState.topCursor, centerCursor: getCenterCursor(entries), @@ -402,7 +402,7 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action const update = { entries, isLoadingMore: false, - hasMoreAfterEnd: newEntries.length > 0, + hasMoreAfterEnd: action.payload.hasMoreAfter ?? prevState.hasMoreAfterEnd, // Keep the previous cursor if request comes empty, to easily extend the range. bottomCursor: newEntries.length > 0 ? action.payload.bottomCursor : prevState.bottomCursor, centerCursor: getCenterCursor(entries), @@ -419,6 +419,8 @@ const logEntriesStateReducer = (prevState: LogEntriesStateParams, action: Action topCursor: null, bottomCursor: null, centerCursor: null, + // Assume there are more pages on both ends unless proven wrong by the + // API with an explicit `false` response. hasMoreBeforeStart: true, hasMoreAfterEnd: true, }; diff --git a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts index 4a6da6063e960..566edcce91318 100644 --- a/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts +++ b/x-pack/plugins/infra/public/containers/logs/log_stream/index.ts @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState, useMemo } from 'react'; +import { useMemo, useEffect } from 'react'; +import useSetState from 'react-use/lib/useSetState'; +import usePrevious from 'react-use/lib/usePrevious'; import { esKuery } from '../../../../../../../src/plugins/data/public'; import { fetchLogEntries } from '../log_entries/api/fetch_log_entries'; import { useTrackedPromise } from '../../../utils/use_tracked_promise'; @@ -21,19 +23,62 @@ interface LogStreamProps { interface LogStreamState { entries: LogEntry[]; + topCursor: LogEntriesCursor | null; + bottomCursor: LogEntriesCursor | null; + hasMoreBefore: boolean; + hasMoreAfter: boolean; +} + +type LoadingState = 'uninitialized' | 'loading' | 'success' | 'error'; + +interface LogStreamReturn extends LogStreamState { fetchEntries: () => void; - loadingState: 'uninitialized' | 'loading' | 'success' | 'error'; + fetchPreviousEntries: () => void; + fetchNextEntries: () => void; + loadingState: LoadingState; + pageLoadingState: LoadingState; } +const INITIAL_STATE: LogStreamState = { + entries: [], + topCursor: null, + bottomCursor: null, + // Assume there are pages available until the API proves us wrong + hasMoreBefore: true, + hasMoreAfter: true, +}; + +const EMPTY_DATA = { + entries: [], + topCursor: null, + bottomCursor: null, +}; + export function useLogStream({ sourceId, startTimestamp, endTimestamp, query, center, -}: LogStreamProps): LogStreamState { +}: LogStreamProps): LogStreamReturn { const { services } = useKibanaContextForPlugin(); - const [entries, setEntries] = useState([]); + const [state, setState] = useSetState(INITIAL_STATE); + + // Ensure the pagination keeps working when the timerange gets extended + const prevStartTimestamp = usePrevious(startTimestamp); + const prevEndTimestamp = usePrevious(endTimestamp); + + useEffect(() => { + if (prevStartTimestamp && prevStartTimestamp > startTimestamp) { + setState({ hasMoreBefore: true }); + } + }, [prevStartTimestamp, startTimestamp, setState]); + + useEffect(() => { + if (prevEndTimestamp && prevEndTimestamp < endTimestamp) { + setState({ hasMoreAfter: true }); + } + }, [prevEndTimestamp, endTimestamp, setState]); const parsedQuery = useMemo(() => { return query @@ -46,7 +91,7 @@ export function useLogStream({ { cancelPreviousOn: 'creation', createPromise: () => { - setEntries([]); + setState(INITIAL_STATE); const fetchPosition = center ? { center } : { before: 'last' }; return fetchLogEntries( @@ -61,26 +106,130 @@ export function useLogStream({ ); }, onResolve: ({ data }) => { - setEntries(data.entries); + setState((prevState) => ({ + ...data, + hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore, + hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter, + })); }, }, [sourceId, startTimestamp, endTimestamp, query] ); - const loadingState = useMemo(() => convertPromiseStateToLoadingState(entriesPromise.state), [ - entriesPromise.state, - ]); + const [previousEntriesPromise, fetchPreviousEntries] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: () => { + if (state.topCursor === null) { + throw new Error( + 'useLogState: Cannot fetch previous entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' + ); + } + + if (!state.hasMoreBefore) { + return Promise.resolve({ data: EMPTY_DATA }); + } + + return fetchLogEntries( + { + sourceId, + startTimestamp, + endTimestamp, + query: parsedQuery, + before: state.topCursor, + }, + services.http.fetch + ); + }, + onResolve: ({ data }) => { + if (!data.entries.length) { + return; + } + setState((prevState) => ({ + entries: [...data.entries, ...prevState.entries], + hasMoreBefore: data.hasMoreBefore ?? prevState.hasMoreBefore, + topCursor: data.topCursor ?? prevState.topCursor, + })); + }, + }, + [sourceId, startTimestamp, endTimestamp, query, state.topCursor] + ); + + const [nextEntriesPromise, fetchNextEntries] = useTrackedPromise( + { + cancelPreviousOn: 'creation', + createPromise: () => { + if (state.bottomCursor === null) { + throw new Error( + 'useLogState: Cannot fetch next entries. No cursor is set.\nEnsure you have called `fetchEntries` at least once.' + ); + } + + if (!state.hasMoreAfter) { + return Promise.resolve({ data: EMPTY_DATA }); + } + + return fetchLogEntries( + { + sourceId, + startTimestamp, + endTimestamp, + query: parsedQuery, + after: state.bottomCursor, + }, + services.http.fetch + ); + }, + onResolve: ({ data }) => { + if (!data.entries.length) { + return; + } + setState((prevState) => ({ + entries: [...prevState.entries, ...data.entries], + hasMoreAfter: data.hasMoreAfter ?? prevState.hasMoreAfter, + bottomCursor: data.bottomCursor ?? prevState.bottomCursor, + })); + }, + }, + [sourceId, startTimestamp, endTimestamp, query, state.bottomCursor] + ); + + const loadingState = useMemo( + () => convertPromiseStateToLoadingState(entriesPromise.state), + [entriesPromise.state] + ); + + const pageLoadingState = useMemo(() => { + const states = [previousEntriesPromise.state, nextEntriesPromise.state]; + + if (states.includes('pending')) { + return 'loading'; + } + + if (states.includes('rejected')) { + return 'error'; + } + + if (states.includes('resolved')) { + return 'success'; + } + + return 'uninitialized'; + }, [previousEntriesPromise.state, nextEntriesPromise.state]); return { - entries, + ...state, fetchEntries, + fetchPreviousEntries, + fetchNextEntries, loadingState, + pageLoadingState, }; } function convertPromiseStateToLoadingState( state: 'uninitialized' | 'pending' | 'resolved' | 'rejected' -): LogStreamState['loadingState'] { +): LoadingState { switch (state) { case 'uninitialized': return 'uninitialized'; diff --git a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts index 9309ad85a3570..6ffa1ad4b0b82 100644 --- a/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts +++ b/x-pack/plugins/infra/server/lib/adapters/log_entries/kibana_log_entries_adapter.ts @@ -35,8 +35,9 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { sourceConfiguration: InfraSourceConfiguration, fields: string[], params: LogEntriesParams - ): Promise { - const { startTimestamp, endTimestamp, query, cursor, size, highlightTerm } = params; + ): Promise<{ documents: LogEntryDocument[]; hasMoreBefore?: boolean; hasMoreAfter?: boolean }> { + const { startTimestamp, endTimestamp, query, cursor, highlightTerm } = params; + const size = params.size ?? LOG_ENTRIES_PAGE_SIZE; const { sortDirection, searchAfterClause } = processCursor(cursor); @@ -72,7 +73,7 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { index: sourceConfiguration.logAlias, ignoreUnavailable: true, body: { - size: typeof size !== 'undefined' ? size : LOG_ENTRIES_PAGE_SIZE, + size: size + 1, // Extra one to test if it has more before or after track_total_hits: false, _source: false, fields, @@ -104,8 +105,22 @@ export class InfraKibanaLogEntriesAdapter implements LogEntriesAdapter { esQuery ); - const hits = sortDirection === 'asc' ? esResult.hits.hits : esResult.hits.hits.reverse(); - return mapHitsToLogEntryDocuments(hits, fields); + const hits = esResult.hits.hits; + const hasMore = hits.length > size; + + if (hasMore) { + hits.pop(); + } + + if (sortDirection === 'desc') { + hits.reverse(); + } + + return { + documents: mapHitsToLogEntryDocuments(hits, fields), + hasMoreBefore: sortDirection === 'desc' ? hasMore : undefined, + hasMoreAfter: sortDirection === 'asc' ? hasMore : undefined, + }; } public async getContainedLogSummaryBuckets( diff --git a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts index cc9d4c749c77d..1cf0afd50b80c 100644 --- a/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts +++ b/x-pack/plugins/infra/server/lib/domains/log_entries_domain/log_entries_domain.ts @@ -74,7 +74,7 @@ export class InfraLogEntriesDomain { requestContext: RequestHandlerContext, sourceId: string, params: LogEntriesAroundParams - ) { + ): Promise<{ entries: LogEntry[]; hasMoreBefore?: boolean; hasMoreAfter?: boolean }> { const { startTimestamp, endTimestamp, center, query, size, highlightTerm } = params; /* @@ -87,14 +87,18 @@ export class InfraLogEntriesDomain { */ const halfSize = (size || LOG_ENTRIES_PAGE_SIZE) / 2; - const entriesBefore = await this.getLogEntries(requestContext, sourceId, { - startTimestamp, - endTimestamp, - query, - cursor: { before: center }, - size: Math.floor(halfSize), - highlightTerm, - }); + const { entries: entriesBefore, hasMoreBefore } = await this.getLogEntries( + requestContext, + sourceId, + { + startTimestamp, + endTimestamp, + query, + cursor: { before: center }, + size: Math.floor(halfSize), + highlightTerm, + } + ); /* * Elasticsearch's `search_after` returns documents after the specified cursor. @@ -108,23 +112,27 @@ export class InfraLogEntriesDomain { ? entriesBefore[entriesBefore.length - 1].cursor : { time: center.time - 1, tiebreaker: 0 }; - const entriesAfter = await this.getLogEntries(requestContext, sourceId, { - startTimestamp, - endTimestamp, - query, - cursor: { after: cursorAfter }, - size: Math.ceil(halfSize), - highlightTerm, - }); + const { entries: entriesAfter, hasMoreAfter } = await this.getLogEntries( + requestContext, + sourceId, + { + startTimestamp, + endTimestamp, + query, + cursor: { after: cursorAfter }, + size: Math.ceil(halfSize), + highlightTerm, + } + ); - return [...entriesBefore, ...entriesAfter]; + return { entries: [...entriesBefore, ...entriesAfter], hasMoreBefore, hasMoreAfter }; } public async getLogEntries( requestContext: RequestHandlerContext, sourceId: string, params: LogEntriesParams - ): Promise { + ): Promise<{ entries: LogEntry[]; hasMoreBefore?: boolean; hasMoreAfter?: boolean }> { const { configuration } = await this.libs.sources.getSourceConfiguration( requestContext.core.savedObjects.client, sourceId @@ -136,7 +144,7 @@ export class InfraLogEntriesDomain { const requiredFields = getRequiredFields(configuration, messageFormattingRules); - const documents = await this.adapter.getLogEntries( + const { documents, hasMoreBefore, hasMoreAfter } = await this.adapter.getLogEntries( requestContext, configuration, requiredFields, @@ -173,7 +181,7 @@ export class InfraLogEntriesDomain { }; }); - return entries; + return { entries, hasMoreBefore, hasMoreAfter }; } public async getLogSummaryBucketsBetween( @@ -323,7 +331,7 @@ export interface LogEntriesAdapter { sourceConfiguration: InfraSourceConfiguration, fields: string[], params: LogEntriesParams - ): Promise; + ): Promise<{ documents: LogEntryDocument[]; hasMoreBefore?: boolean; hasMoreAfter?: boolean }>; getContainedLogSummaryBuckets( requestContext: RequestHandlerContext, diff --git a/x-pack/plugins/infra/server/routes/log_entries/entries.ts b/x-pack/plugins/infra/server/routes/log_entries/entries.ts index c1f63d9c29577..2baf3fd7aa990 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/entries.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/entries.ts @@ -34,14 +34,21 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) } = payload; let entries; + let hasMoreBefore; + let hasMoreAfter; + if ('center' in payload) { - entries = await logEntries.getLogEntriesAround(requestContext, sourceId, { - startTimestamp, - endTimestamp, - query: parseFilterQuery(query), - center: payload.center, - size, - }); + ({ entries, hasMoreBefore, hasMoreAfter } = await logEntries.getLogEntriesAround( + requestContext, + sourceId, + { + startTimestamp, + endTimestamp, + query: parseFilterQuery(query), + center: payload.center, + size, + } + )); } else { let cursor: LogEntriesParams['cursor']; if ('before' in payload) { @@ -50,13 +57,17 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) cursor = { after: payload.after }; } - entries = await logEntries.getLogEntries(requestContext, sourceId, { - startTimestamp, - endTimestamp, - query: parseFilterQuery(query), - cursor, - size, - }); + ({ entries, hasMoreBefore, hasMoreAfter } = await logEntries.getLogEntries( + requestContext, + sourceId, + { + startTimestamp, + endTimestamp, + query: parseFilterQuery(query), + cursor, + size, + } + )); } const hasEntries = entries.length > 0; @@ -67,6 +78,8 @@ export const initLogEntriesRoute = ({ framework, logEntries }: InfraBackendLibs) entries, topCursor: hasEntries ? entries[0].cursor : null, bottomCursor: hasEntries ? entries[entries.length - 1].cursor : null, + hasMoreBefore, + hasMoreAfter, }, }), }); diff --git a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts index cc8483fb5c658..b315d22c47165 100644 --- a/x-pack/plugins/infra/server/routes/log_entries/highlights.ts +++ b/x-pack/plugins/infra/server/routes/log_entries/highlights.ts @@ -79,7 +79,7 @@ export const initLogEntriesHighlightsRoute = ({ framework, logEntries }: InfraBa return response.ok({ body: logEntriesHighlightsResponseRT.encode({ - data: entriesPerHighlightTerm.map((entries) => { + data: entriesPerHighlightTerm.map(({ entries }) => { if (entries.length > 0) { return { entries, From 169dcef2bf478e0ed5014705c12f4cb102a76918 Mon Sep 17 00:00:00 2001 From: Dima Arnautov Date: Thu, 12 Nov 2020 15:16:23 +0100 Subject: [PATCH 6/9] [ML] Persisted URL state for the "Anomaly detection jobs" page (#83149) * [ML] table config in the URL state * [ML] fix job list on the management page * [ML] store query filter in the URL * [ML] fix context for the management page * [ML] update module_list_card.tsx in Logs UI * [ML] fix unit tests * [ML] fix unit tests * [ML] fix unit tests * [ML] move utils functions * [ML] url generator to support both job and group ids --- .../MachineLearningLinks/MLLink.test.tsx | 2 +- .../setup_flyout/module_list_card.tsx | 62 ++++-- x-pack/plugins/infra/public/types.ts | 2 + .../ml/common/util/string_utils.test.ts | 22 +- x-pack/plugins/ml/common/util/string_utils.ts | 8 + .../analytics_list/analytics_list.tsx | 6 +- .../job_filter_bar/{index.js => index.ts} | 0 .../job_filter_bar/job_filter_bar.js | 210 ------------------ .../job_filter_bar/job_filter_bar.tsx | 163 ++++++++++++++ .../components/jobs_list/jobs_list.js | 13 +- .../jobs_list_view/jobs_list_view.js | 22 +- .../jobs/jobs_list/components/utils.d.ts | 2 - .../jobs/jobs_list/components/utils.js | 19 -- .../jobs/jobs_list/components/utils.test.ts | 14 +- .../application/jobs/jobs_list/jobs.tsx | 45 +++- .../jobs_list_page/jobs_list_page.tsx | 99 ++++++--- .../anomaly_detection_urls_generator.ts | 19 +- .../ml_url_generator/ml_url_generator.test.ts | 4 +- .../ml_popover/jobs_table/jobs_table.test.tsx | 4 +- 19 files changed, 391 insertions(+), 325 deletions(-) rename x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/{index.js => index.ts} (100%) delete mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js create mode 100644 x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx diff --git a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx index be00364cab92e..30d4bb34ea345 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/MachineLearningLinks/MLLink.test.tsx @@ -19,6 +19,6 @@ test('MLLink produces the correct URL', async () => { ); expect(href).toMatchInlineSnapshot( - `"/app/ml/jobs?mlManagement=(groupIds:!(apm),jobId:!(something))&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-5h,to:now-2h))"` + `"/app/ml/jobs?_a=(queryText:'id:(something)%20groups:(apm)')&_g=(refreshInterval:(pause:!t,value:0),time:(from:now-5h,to:now-2h))"` ); }); diff --git a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx index 67f2c8d58ec0d..39c21fdc228df 100644 --- a/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_analysis_setup/setup_flyout/module_list_card.tsx @@ -6,10 +6,11 @@ import { EuiCard, EuiIcon, EuiButtonEmpty, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { SetupStatus } from '../../../../../common/log_analysis'; import { CreateJobButton, RecreateJobButton } from '../../log_analysis_setup/create_job_button'; -import { useLinkProps } from '../../../../hooks/use_link_props'; +import { useKibanaContextForPlugin } from '../../../../hooks/use_kibana'; +import { mountReactNode } from '../../../../../../../../src/core/public/utils'; export const LogAnalysisModuleListCard: React.FC<{ jobId: string; @@ -26,6 +27,39 @@ export const LogAnalysisModuleListCard: React.FC<{ moduleStatus, onViewSetup, }) => { + const { + services: { + ml, + application: { navigateToUrl }, + notifications: { toasts }, + }, + } = useKibanaContextForPlugin(); + + const [viewInMlLink, setViewInMlLink] = useState(''); + + const getMlUrl = async () => { + if (!ml.urlGenerator) { + toasts.addWarning({ + title: mountReactNode( + + ), + }); + return; + } + setViewInMlLink(await ml.urlGenerator.createUrl({ page: 'jobs', pageState: { jobId } })); + }; + + useEffect(() => { + getMlUrl(); + }); + + const navigateToMlApp = async () => { + await navigateToUrl(viewInMlLink); + }; + const moduleIcon = moduleStatus.type === 'required' ? ( @@ -33,12 +67,6 @@ export const LogAnalysisModuleListCard: React.FC<{ ); - const viewInMlLinkProps = useLinkProps({ - app: 'ml', - pathname: '/jobs', - search: { mlManagement: `(jobId:${jobId})` }, - }); - const moduleSetupButton = moduleStatus.type === 'required' ? ( @@ -50,13 +78,17 @@ export const LogAnalysisModuleListCard: React.FC<{ ) : ( <> - - - - + {viewInMlLink ? ( + <> + + + + + + ) : null} ); diff --git a/x-pack/plugins/infra/public/types.ts b/x-pack/plugins/infra/public/types.ts index 6ff699066eb15..116345b35fdce 100644 --- a/x-pack/plugins/infra/public/types.ts +++ b/x-pack/plugins/infra/public/types.ts @@ -18,6 +18,7 @@ import type { ObservabilityPluginStart, } from '../../observability/public'; import type { SpacesPluginStart } from '../../spaces/public'; +import { MlPluginStart } from '../../ml/public'; // Our own setup and start contract values export type InfraClientSetupExports = void; @@ -38,6 +39,7 @@ export interface InfraClientStartDeps { spaces: SpacesPluginStart; triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; usageCollection: UsageCollectionStart; + ml: MlPluginStart; } export type InfraClientCoreSetup = CoreSetup; diff --git a/x-pack/plugins/ml/common/util/string_utils.test.ts b/x-pack/plugins/ml/common/util/string_utils.test.ts index 8afc7e52c9fa5..3503e2be35e86 100644 --- a/x-pack/plugins/ml/common/util/string_utils.test.ts +++ b/x-pack/plugins/ml/common/util/string_utils.test.ts @@ -4,7 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { renderTemplate, getMedianStringLength, stringHash } from './string_utils'; +import { + renderTemplate, + getMedianStringLength, + stringHash, + getGroupQueryText, +} from './string_utils'; const strings: string[] = [ 'foo', @@ -54,4 +59,19 @@ describe('ML - string utils', () => { expect(hash1).not.toBe(hash2); }); }); + + describe('getGroupQueryText', () => { + const groupIdOne = 'test_group_id_1'; + const groupIdTwo = 'test_group_id_2'; + + it('should get query string for selected group ids', () => { + const actual = getGroupQueryText([groupIdOne, groupIdTwo]); + expect(actual).toBe(`groups:(${groupIdOne} or ${groupIdTwo})`); + }); + + it('should get query string for selected group id', () => { + const actual = getGroupQueryText([groupIdOne]); + expect(actual).toBe(`groups:(${groupIdOne})`); + }); + }); }); diff --git a/x-pack/plugins/ml/common/util/string_utils.ts b/x-pack/plugins/ml/common/util/string_utils.ts index b4591fd2943e6..4691bac0a065a 100644 --- a/x-pack/plugins/ml/common/util/string_utils.ts +++ b/x-pack/plugins/ml/common/util/string_utils.ts @@ -39,3 +39,11 @@ export function stringHash(str: string): number { } return hash < 0 ? hash * -2 : hash; } + +export function getGroupQueryText(groupIds: string[]): string { + return `groups:(${groupIds.join(' or ')})`; +} + +export function getJobQueryText(jobIds: string | string[]): string { + return Array.isArray(jobIds) ? `id:(${jobIds.join(' OR ')})` : jobIds; +} diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx index 8ed2436843e0e..17ef84179ce63 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_management/components/analytics_list/analytics_list.tsx @@ -30,15 +30,13 @@ import { getTaskStateBadge, getJobTypeBadge, useColumns } from './use_columns'; import { ExpandedRow } from './expanded_row'; import { AnalyticStatsBarStats, StatsBar } from '../../../../../components/stats_bar'; import { CreateAnalyticsButton } from '../create_analytics_button'; -import { - getSelectedIdFromUrl, - getGroupQueryText, -} from '../../../../../jobs/jobs_list/components/utils'; +import { getSelectedIdFromUrl } from '../../../../../jobs/jobs_list/components/utils'; import { SourceSelection } from '../source_selection'; import { filterAnalytics } from '../../../../common/search_bar_filters'; import { AnalyticsEmptyPrompt } from './empty_prompt'; import { useTableSettings } from './use_table_settings'; import { RefreshAnalyticsListButton } from '../refresh_analytics_list_button'; +import { getGroupQueryText } from '../../../../../../../common/util/string_utils'; const filters: EuiSearchBarProps['filters'] = [ { diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/index.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/index.ts similarity index 100% rename from x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/index.js rename to x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/index.ts diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js deleted file mode 100644 index 08373542c1234..0000000000000 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.js +++ /dev/null @@ -1,210 +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 PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; - -import { ml } from '../../../../services/ml_api_service'; -import { JobGroup } from '../job_group'; -import { - getGroupQueryText, - getSelectedIdFromUrl, - clearSelectedJobIdFromUrl, - getJobQueryText, -} from '../utils'; - -import { EuiSearchBar, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; - -function loadGroups() { - return ml.jobs - .groups() - .then((groups) => { - return groups.map((g) => ({ - value: g.id, - view: ( -
- -   - - - -
- ), - })); - }) - .catch((error) => { - console.log(error); - return []; - }); -} - -export class JobFilterBar extends Component { - constructor(props) { - super(props); - - this.state = { error: null }; - this.setFilters = props.setFilters; - } - - urlFilterIdCleared = false; - - componentDidMount() { - // If job id is selected in url, filter table to that id - let defaultQueryText; - const { jobId, groupIds } = getSelectedIdFromUrl(window.location.href); - - if (groupIds !== undefined) { - defaultQueryText = getGroupQueryText(groupIds); - } else if (jobId !== undefined) { - defaultQueryText = getJobQueryText(jobId); - } - - if (defaultQueryText !== undefined) { - this.setState( - { - defaultQueryText, - }, - () => { - // trigger onChange with query for job id to trigger table filter - const query = EuiSearchBar.Query.parse(defaultQueryText); - this.onChange({ query }); - } - ); - } - } - - onChange = ({ query, error }) => { - if (error) { - this.setState({ error }); - } else { - if (query.text === '' && this.urlFilterIdCleared === false) { - this.urlFilterIdCleared = true; - clearSelectedJobIdFromUrl(window.location.href); - } - let clauses = []; - if (query && query.ast !== undefined && query.ast.clauses !== undefined) { - clauses = query.ast.clauses; - } - this.setFilters(clauses); - this.setState({ error: null }); - } - }; - - render() { - const { error, defaultQueryText } = this.state; - const filters = [ - { - type: 'field_value_toggle_group', - field: 'job_state', - items: [ - { - value: 'opened', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.openedLabel', { - defaultMessage: 'Opened', - }), - }, - { - value: 'closed', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.closedLabel', { - defaultMessage: 'Closed', - }), - }, - { - value: 'failed', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.failedLabel', { - defaultMessage: 'Failed', - }), - }, - ], - }, - { - type: 'field_value_toggle_group', - field: 'datafeed_state', - items: [ - { - value: 'started', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.startedLabel', { - defaultMessage: 'Started', - }), - }, - { - value: 'stopped', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.stoppedLabel', { - defaultMessage: 'Stopped', - }), - }, - ], - }, - { - type: 'field_value_selection', - field: 'groups', - name: i18n.translate('xpack.ml.jobsList.jobFilterBar.groupLabel', { - defaultMessage: 'Group', - }), - multiSelect: 'or', - cache: 10000, - options: () => loadGroups(), - }, - ]; - // if prop flag for default filter set to true - // set defaultQuery to job id and force trigger filter with onChange - pass it the query object for the job id - return ( - - - {defaultQueryText === undefined && ( - - )} - {defaultQueryText !== undefined && ( - - )} - - - - - - ); - } -} -JobFilterBar.propTypes = { - setFilters: PropTypes.func.isRequired, -}; - -function getError(error) { - if (error) { - return i18n.translate('xpack.ml.jobsList.jobFilterBar.invalidSearchErrorMessage', { - defaultMessage: 'Invalid search: {errorMessage}', - values: { errorMessage: error.message }, - }); - } - - return ''; -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx new file mode 100644 index 0000000000000..f0fa62b7a3d8a --- /dev/null +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/job_filter_bar/job_filter_bar.tsx @@ -0,0 +1,163 @@ +/* + * 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, { FC, Fragment, useCallback, useEffect, useMemo, useState } from 'react'; + +import { + EuiSearchBar, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + SearchFilterConfig, + EuiSearchBarProps, + Query, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +// @ts-ignore +import { JobGroup } from '../job_group'; +import { useMlKibana } from '../../../../contexts/kibana'; + +interface JobFilterBarProps { + jobId: string; + groupIds: string[]; + setFilters: (query: Query | null) => void; + queryText?: string; +} + +export const JobFilterBar: FC = ({ queryText, setFilters }) => { + const [error, setError] = useState(null); + const { + services: { + mlServices: { mlApiServices }, + }, + } = useMlKibana(); + + const loadGroups = useCallback(async () => { + try { + const response = await mlApiServices.jobs.groups(); + return response.map((g: any) => ({ + value: g.id, + view: ( +
+ +   + + + +
+ ), + })); + } catch (e) { + return []; + } + }, []); + + const queryInstance: Query = useMemo(() => { + return EuiSearchBar.Query.parse(queryText ?? ''); + }, [queryText]); + + const onChange: EuiSearchBarProps['onChange'] = ({ query, error: queryError }) => { + if (error) { + setError(queryError); + } else { + setFilters(query); + setError(null); + } + }; + + useEffect(() => { + setFilters(queryInstance); + }, []); + + const filters: SearchFilterConfig[] = useMemo( + () => [ + { + type: 'field_value_toggle_group', + field: 'job_state', + items: [ + { + value: 'opened', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.openedLabel', { + defaultMessage: 'Opened', + }), + }, + { + value: 'closed', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.closedLabel', { + defaultMessage: 'Closed', + }), + }, + { + value: 'failed', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.failedLabel', { + defaultMessage: 'Failed', + }), + }, + ], + }, + { + type: 'field_value_toggle_group', + field: 'datafeed_state', + items: [ + { + value: 'started', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.startedLabel', { + defaultMessage: 'Started', + }), + }, + { + value: 'stopped', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.stoppedLabel', { + defaultMessage: 'Stopped', + }), + }, + ], + }, + { + type: 'field_value_selection', + field: 'groups', + name: i18n.translate('xpack.ml.jobsList.jobFilterBar.groupLabel', { + defaultMessage: 'Group', + }), + multiSelect: 'or', + cache: 10000, + options: () => loadGroups(), + }, + ], + [] + ); + + const errorText = useMemo(() => { + if (error === null) return ''; + + return i18n.translate('xpack.ml.jobsList.jobFilterBar.invalidSearchErrorMessage', { + defaultMessage: 'Invalid search: {errorMessage}', + values: { errorMessage: error.message }, + }); + }, [error]); + + return ( + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js index c0abab6b52cf1..8a05cd51e4d65 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list/jobs_list.js @@ -22,7 +22,6 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { AnomalyDetectionJobIdLink } from './job_id_link'; -const PAGE_SIZE = 10; const PAGE_SIZE_OPTIONS = [10, 25, 50]; // 'isManagementTable' bool prop to determine when to configure table for use in Kibana management page @@ -32,11 +31,7 @@ export class JobsList extends Component { this.state = { jobsSummaryList: props.jobsSummaryList, - pageIndex: 0, - pageSize: PAGE_SIZE, itemIdToExpandedRowMap: {}, - sortField: 'id', - sortDirection: 'asc', }; } @@ -54,7 +49,7 @@ export class JobsList extends Component { const { field: sortField, direction: sortDirection } = sort; - this.setState({ + this.props.onJobsViewStateUpdate({ pageIndex, pageSize, sortField, @@ -88,7 +83,7 @@ export class JobsList extends Component { pageStart = Math.floor((listLength - 1) / size) * size; // set the state out of the render cycle setTimeout(() => { - this.setState({ + this.props.onJobsViewStateUpdate({ pageIndex: pageStart / size, }); }, 0); @@ -298,7 +293,7 @@ export class JobsList extends Component { }); } - const { pageIndex, pageSize, sortField, sortDirection } = this.state; + const { pageIndex, pageSize, sortField, sortDirection } = this.props.jobsViewState; const { pageOfItems, totalItemCount } = this.getPageOfJobs( pageIndex, @@ -368,6 +363,8 @@ JobsList.propTypes = { refreshJobs: PropTypes.func, selectedJobsCount: PropTypes.number.isRequired, loading: PropTypes.bool, + jobsViewState: PropTypes.object, + onJobsViewStateUpdate: PropTypes.func, }; JobsList.defaultProps = { isManagementTable: false, diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js index 9eb7a03f0f5d7..570172abb28c1 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/jobs_list_view/jobs_list_view.js @@ -222,8 +222,14 @@ export class JobsListView extends Component { this.setState({ selectedJobs }); } - setFilters = (filterClauses) => { + setFilters = (query) => { + const filterClauses = (query && query.ast && query.ast.clauses) || []; const filteredJobsSummaryList = filterJobs(this.state.jobsSummaryList, filterClauses); + + this.props.onJobsViewStateUpdate({ + queryText: query?.text, + }); + this.setState({ filteredJobsSummaryList, filterClauses }, () => { this.refreshSelectedJobs(); }); @@ -358,7 +364,10 @@ export class JobsListView extends Component {
- +
@@ -434,7 +445,10 @@ export class JobsListView extends Component { showDeleteJobModal={this.showDeleteJobModal} refreshJobs={() => this.refreshJobSummaryList(true)} /> - + this.refreshJobSummaryList(true)} + jobsViewState={this.props.jobsViewState} + onJobsViewStateUpdate={this.props.onJobsViewStateUpdate} selectedJobsCount={this.state.selectedJobs.length} loading={loading} /> diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts index 75d6b149fda08..b781199c85237 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.d.ts @@ -5,6 +5,4 @@ */ export function getSelectedIdFromUrl(str: string): { groupIds?: string[]; jobId?: string }; -export function getGroupQueryText(arr: string[]): string; -export function getJobQueryText(arr: string | string[]): string; export function clearSelectedJobIdFromUrl(str: string): void; diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js index bc85153928a4b..397062248689d 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.js @@ -395,22 +395,3 @@ export function getSelectedIdFromUrl(url) { } return result; } - -export function getGroupQueryText(groupIds) { - return `groups:(${groupIds.join(' or ')})`; -} - -export function getJobQueryText(jobIds) { - return Array.isArray(jobIds) ? `id:(${jobIds.join(' OR ')})` : jobIds; -} - -export function clearSelectedJobIdFromUrl(url) { - if (typeof url === 'string') { - url = decodeURIComponent(url); - if (url.includes('mlManagement') && (url.includes('jobId') || url.includes('groupIds'))) { - const urlParams = getUrlVars(url); - const clearedParams = `jobs?_g=${urlParams._g}`; - window.history.replaceState({}, document.title, clearedParams); - } - } -} diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts index e4c3c21c5a54a..4414be0b4fdcb 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/components/utils.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { getGroupQueryText, getSelectedIdFromUrl } from './utils'; +import { getSelectedIdFromUrl } from './utils'; describe('ML - Jobs List utils', () => { const jobId = 'test_job_id_1'; @@ -32,16 +32,4 @@ describe('ML - Jobs List utils', () => { expect(actual).toStrictEqual(expected); }); }); - - describe('getGroupQueryText', () => { - it('should get query string for selected group ids', () => { - const actual = getGroupQueryText([groupIdOne, groupIdTwo]); - expect(actual).toBe(`groups:(${groupIdOne} or ${groupIdTwo})`); - }); - - it('should get query string for selected group id', () => { - const actual = getGroupQueryText([groupIdOne]); - expect(actual).toBe(`groups:(${groupIdOne})`); - }); - }); }); diff --git a/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx b/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx index 1e45f28594572..4c6469f6800a7 100644 --- a/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx +++ b/x-pack/plugins/ml/public/application/jobs/jobs_list/jobs.tsx @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC } from 'react'; - +import React, { FC, useCallback, useMemo } from 'react'; import { NavigationMenu } from '../../components/navigation_menu'; - // @ts-ignore import { JobsListView } from './components/jobs_list_view/index'; +import { useUrlState } from '../../util/url_state'; interface JobsPageProps { blockRefresh?: boolean; @@ -18,11 +17,49 @@ interface JobsPageProps { lastRefresh?: number; } +export interface AnomalyDetectionJobsListState { + pageSize: number; + pageIndex: number; + sortField: string; + sortDirection: string; + queryText?: string; +} + +export const getDefaultAnomalyDetectionJobsListState = (): AnomalyDetectionJobsListState => ({ + pageIndex: 0, + pageSize: 10, + sortField: 'id', + sortDirection: 'asc', +}); + export const JobsPage: FC = (props) => { + const [appState, setAppState] = useUrlState('_a'); + + const jobListState: AnomalyDetectionJobsListState = useMemo(() => { + return { + ...getDefaultAnomalyDetectionJobsListState(), + ...(appState ?? {}), + }; + }, [appState]); + + const onJobsViewStateUpdate = useCallback( + (update: Partial) => { + setAppState({ + ...jobListState, + ...update, + }); + }, + [appState, setAppState] + ); + return (
- +
); }; diff --git a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx index 61dfea8897e82..ad4b9ad78902b 100644 --- a/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx +++ b/x-pack/plugins/ml/public/application/management/jobs_list/components/jobs_list_page/jobs_list_page.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useEffect, useState, Fragment, FC } from 'react'; +import React, { useEffect, useState, Fragment, FC, useMemo, useCallback } from 'react'; import { Router } from 'react-router-dom'; import { i18n } from '@kbn/i18n'; import { CoreStart } from 'kibana/public'; @@ -35,6 +35,11 @@ import { JobsListView } from '../../../../jobs/jobs_list/components/jobs_list_vi import { DataFrameAnalyticsList } from '../../../../data_frame_analytics/pages/analytics_management/components/analytics_list'; import { AccessDeniedPage } from '../access_denied_page'; import { SharePluginStart } from '../../../../../../../../../src/plugins/share/public'; +import { + AnomalyDetectionJobsListState, + getDefaultAnomalyDetectionJobsListState, +} from '../../../../jobs/jobs_list/jobs'; +import { getMlGlobalServices } from '../../../../app'; interface Tab { 'data-test-subj': string; @@ -43,38 +48,60 @@ interface Tab { content: any; } -function getTabs(isMlEnabledInSpace: boolean): Tab[] { - return [ - { - 'data-test-subj': 'mlStackManagementJobsListAnomalyDetectionTab', - id: 'anomaly_detection_jobs', - name: i18n.translate('xpack.ml.management.jobsList.anomalyDetectionTab', { - defaultMessage: 'Anomaly detection', - }), - content: ( - - - - - ), - }, - { - 'data-test-subj': 'mlStackManagementJobsListAnalyticsTab', - id: 'analytics_jobs', - name: i18n.translate('xpack.ml.management.jobsList.analyticsTab', { - defaultMessage: 'Analytics', - }), - content: ( - - - - - ), +function useTabs(isMlEnabledInSpace: boolean): Tab[] { + const [jobsViewState, setJobsViewState] = useState( + getDefaultAnomalyDetectionJobsListState() + ); + + const updateState = useCallback( + (update: Partial) => { + setJobsViewState({ + ...jobsViewState, + ...update, + }); }, - ]; + [jobsViewState] + ); + + return useMemo( + () => [ + { + 'data-test-subj': 'mlStackManagementJobsListAnomalyDetectionTab', + id: 'anomaly_detection_jobs', + name: i18n.translate('xpack.ml.management.jobsList.anomalyDetectionTab', { + defaultMessage: 'Anomaly detection', + }), + content: ( + + + + + ), + }, + { + 'data-test-subj': 'mlStackManagementJobsListAnalyticsTab', + id: 'analytics_jobs', + name: i18n.translate('xpack.ml.management.jobsList.analyticsTab', { + defaultMessage: 'Analytics', + }), + content: ( + + + + + ), + }, + ], + [isMlEnabledInSpace, jobsViewState, updateState] + ); } export const JobsListPage: FC<{ @@ -85,7 +112,7 @@ export const JobsListPage: FC<{ const [initialized, setInitialized] = useState(false); const [accessDenied, setAccessDenied] = useState(false); const [isMlEnabledInSpace, setIsMlEnabledInSpace] = useState(false); - const tabs = getTabs(isMlEnabledInSpace); + const tabs = useTabs(isMlEnabledInSpace); const [currentTabId, setCurrentTabId] = useState(tabs[0].id); const I18nContext = coreStart.i18n.Context; @@ -129,7 +156,7 @@ export const JobsListPage: FC<{ setCurrentTabId(id); }} size="s" - tabs={getTabs(isMlEnabledInSpace)} + tabs={tabs} initialSelectedTab={tabs[0]} /> ); @@ -142,7 +169,9 @@ export const JobsListPage: FC<{ return ( - + = { + ...(queryTextArr.length > 0 ? { queryText: queryTextArr.join(' ') } : {}), }; - url = setStateToKbnUrl( - 'mlManagement', + url = setStateToKbnUrl>( + '_a', queryState, { useHash: false, storeInHashQuery: false }, url diff --git a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts index 754f5bec57a07..e7f12ead3ffe9 100644 --- a/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts +++ b/x-pack/plugins/ml/public/ml_url_generator/ml_url_generator.test.ts @@ -30,7 +30,7 @@ describe('MlUrlGenerator', () => { jobId: 'fq_single_1', }, }); - expect(url).toBe('/app/ml/jobs?mlManagement=(jobId:fq_single_1)'); + expect(url).toBe('/app/ml/jobs?_a=(queryText:fq_single_1)'); }); it('should generate valid URL for the Anomaly Detection job management page for groupIds', async () => { @@ -40,7 +40,7 @@ describe('MlUrlGenerator', () => { groupIds: ['farequote', 'categorization'], }, }); - expect(url).toBe('/app/ml/jobs?mlManagement=(groupIds:!(farequote,categorization))'); + expect(url).toBe("/app/ml/jobs?_a=(queryText:'groups:(farequote%20or%20categorization)')"); }); it('should generate valid URL for the page for selecting the type of anomaly detection job to create', async () => { diff --git a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx index 156475f63aa65..b0965f8708558 100644 --- a/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/ml_popover/jobs_table/jobs_table.test.tsx @@ -55,7 +55,7 @@ describe('JobsTableComponent', () => { '[data-test-subj="jobs-table-link"]' ); await waitFor(() => - expect(href).toEqual('/app/ml/jobs?mlManagement=(jobId:linux_anomalous_network_activity_ecs)') + expect(href).toEqual('/app/ml/jobs?_a=(queryText:linux_anomalous_network_activity_ecs)') ); }); @@ -72,7 +72,7 @@ describe('JobsTableComponent', () => { '[data-test-subj="jobs-table-link"]' ); await waitFor(() => - expect(href).toEqual("/app/ml/jobs?mlManagement=(jobId:'job%20id%20with%20spaces')") + expect(href).toEqual("/app/ml/jobs?_a=(queryText:'job%20id%20with%20spaces')") ); }); From 35656b9921d1a0adcfeb1ad2ca54f9d96aa601f4 Mon Sep 17 00:00:00 2001 From: Scotty Bollinger Date: Thu, 12 Nov 2020 08:28:35 -0600 Subject: [PATCH 7/9] Add additional sources routes (#83227) These were missed in #83125 --- .../routes/workplace_search/sources.test.ts | 188 ++++++++++++++++++ .../server/routes/workplace_search/sources.ts | 124 ++++++++++++ 2 files changed, 312 insertions(+) diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts index 9f2b4121351bc..6d22002222a66 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.test.ts @@ -7,6 +7,8 @@ import { MockRouter, mockRequestHandler, mockDependencies } from '../../__mocks__'; import { + registerAccountSourcesRoute, + registerAccountSourcesStatusRoute, registerAccountSourceRoute, registerAccountCreateSourceRoute, registerAccountSourceDocumentsRoute, @@ -15,6 +17,9 @@ import { registerAccountSourceSettingsRoute, registerAccountPreSourceRoute, registerAccountPrepareSourcesRoute, + registerAccountSourceSearchableRoute, + registerOrgSourcesRoute, + registerOrgSourcesStatusRoute, registerOrgSourceRoute, registerOrgCreateSourceRoute, registerOrgSourceDocumentsRoute, @@ -23,6 +28,7 @@ import { registerOrgSourceSettingsRoute, registerOrgPreSourceRoute, registerOrgPrepareSourcesRoute, + registerOrgSourceSearchableRoute, registerOrgSourceOauthConfigurationsRoute, registerOrgSourceOauthConfigurationRoute, } from './sources'; @@ -38,6 +44,60 @@ const mockConfig = { }; describe('sources routes', () => { + describe('GET /api/workplace_search/account/sources', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/account/sources', + payload: 'params', + }); + + registerAccountSourcesRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/sources', + }); + }); + }); + + describe('GET /api/workplace_search/account/sources/status', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/account/sources/status', + payload: 'params', + }); + + registerAccountSourcesStatusRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/sources/status', + }); + }); + }); + describe('GET /api/workplace_search/account/sources/{id}', () => { let mockRouter: MockRouter; @@ -351,6 +411,97 @@ describe('sources routes', () => { }); }); + describe('PUT /api/workplace_search/sources/{id}/searchable', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'put', + path: '/api/workplace_search/sources/{id}/searchable', + payload: 'body', + }); + + registerAccountSourceSearchableRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + const mockRequest = { + params: { + id: '123', + }, + body: { + searchable: true, + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/sources/123/searchable', + body: mockRequest.body, + }); + }); + }); + + describe('GET /api/workplace_search/org/sources', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/sources', + payload: 'params', + }); + + registerOrgSourcesRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/sources', + }); + }); + }); + + describe('GET /api/workplace_search/org/sources/status', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'get', + path: '/api/workplace_search/org/sources/status', + payload: 'params', + }); + + registerOrgSourcesStatusRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + mockRouter.callRoute({}); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/sources/status', + }); + }); + }); + describe('GET /api/workplace_search/org/sources/{id}', () => { let mockRouter: MockRouter; @@ -664,6 +815,43 @@ describe('sources routes', () => { }); }); + describe('PUT /api/workplace_search/org/sources/{id}/searchable', () => { + let mockRouter: MockRouter; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates a request handler', () => { + mockRouter = new MockRouter({ + method: 'put', + path: '/api/workplace_search/org/sources/{id}/searchable', + payload: 'body', + }); + + registerOrgSourceSearchableRoute({ + ...mockDependencies, + router: mockRouter.router, + }); + + const mockRequest = { + params: { + id: '123', + }, + body: { + searchable: true, + }, + }; + + mockRouter.callRoute(mockRequest); + + expect(mockRequestHandler.createRequest).toHaveBeenCalledWith({ + path: '/ws/org/sources/123/searchable', + body: mockRequest.body, + }); + }); + }); + describe('GET /api/workplace_search/org/settings/connectors', () => { let mockRouter: MockRouter; diff --git a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts index f496628d02379..efef53440117e 100644 --- a/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts +++ b/x-pack/plugins/enterprise_search/server/routes/workplace_search/sources.ts @@ -25,6 +25,40 @@ const oAuthConfigSchema = schema.object({ consumer_key: schema.string(), }); +export function registerAccountSourcesRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/account/sources', + validate: false, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/sources', + })(context, request, response); + } + ); +} + +export function registerAccountSourcesStatusRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/account/sources/status', + validate: false, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/sources/status', + })(context, request, response); + } + ); +} + export function registerAccountSourceRoute({ router, enterpriseSearchRequestHandler, @@ -228,6 +262,65 @@ export function registerAccountPrepareSourcesRoute({ ); } +export function registerAccountSourceSearchableRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.put( + { + path: '/api/workplace_search/sources/{id}/searchable', + validate: { + body: schema.object({ + searchable: schema.boolean(), + }), + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/sources/${request.params.id}/searchable`, + body: request.body, + })(context, request, response); + } + ); +} + +export function registerOrgSourcesRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/sources', + validate: false, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/sources', + })(context, request, response); + } + ); +} + +export function registerOrgSourcesStatusRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.get( + { + path: '/api/workplace_search/org/sources/status', + validate: false, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: '/ws/org/sources/status', + })(context, request, response); + } + ); +} + export function registerOrgSourceRoute({ router, enterpriseSearchRequestHandler, @@ -431,6 +524,31 @@ export function registerOrgPrepareSourcesRoute({ ); } +export function registerOrgSourceSearchableRoute({ + router, + enterpriseSearchRequestHandler, +}: RouteDependencies) { + router.put( + { + path: '/api/workplace_search/org/sources/{id}/searchable', + validate: { + body: schema.object({ + searchable: schema.boolean(), + }), + params: schema.object({ + id: schema.string(), + }), + }, + }, + async (context, request, response) => { + return enterpriseSearchRequestHandler.createRequest({ + path: `/ws/org/sources/${request.params.id}/searchable`, + body: request.body, + })(context, request, response); + } + ); +} + export function registerOrgSourceOauthConfigurationsRoute({ router, enterpriseSearchRequestHandler, @@ -522,6 +640,8 @@ export function registerOrgSourceOauthConfigurationRoute({ } export const registerSourcesRoutes = (dependencies: RouteDependencies) => { + registerAccountSourcesRoute(dependencies); + registerAccountSourcesStatusRoute(dependencies); registerAccountSourceRoute(dependencies); registerAccountCreateSourceRoute(dependencies); registerAccountSourceDocumentsRoute(dependencies); @@ -530,6 +650,9 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => { registerAccountSourceSettingsRoute(dependencies); registerAccountPreSourceRoute(dependencies); registerAccountPrepareSourcesRoute(dependencies); + registerAccountSourceSearchableRoute(dependencies); + registerOrgSourcesRoute(dependencies); + registerOrgSourcesStatusRoute(dependencies); registerOrgSourceRoute(dependencies); registerOrgCreateSourceRoute(dependencies); registerOrgSourceDocumentsRoute(dependencies); @@ -538,6 +661,7 @@ export const registerSourcesRoutes = (dependencies: RouteDependencies) => { registerOrgSourceSettingsRoute(dependencies); registerOrgPreSourceRoute(dependencies); registerOrgPrepareSourcesRoute(dependencies); + registerOrgSourceSearchableRoute(dependencies); registerOrgSourceOauthConfigurationsRoute(dependencies); registerOrgSourceOauthConfigurationRoute(dependencies); }; From c3e57943ad377e29b9d6f4a2508a7d7ed1e0f06f Mon Sep 17 00:00:00 2001 From: Patrick Mueller Date: Thu, 12 Nov 2020 09:32:22 -0500 Subject: [PATCH 8/9] [alerts] add executionStatus to event log doc for action execute (#82401) resolves https://github.com/elastic/kibana/issues/79785 Until now, the execution status was available in the the event log document for the execute action. In this PR we add it. The event log is extended to add the following fields: - `kibana.alerting.status` - from executionStatus.status - `event.reason` - from executionStatus.error.reason The date from the executionStatus and start date in the event log will be set to the same value. Previously, errors encountered while trying to execute an alert executor, eg decrypting the alert, would not end up with an event doc generated. Now they will. In addition, there were a few places where events that could have had the action group in them did not, and one where the instance id was undefined - those were fixed up. --- .../server/alert_instance/alert_instance.ts | 4 + .../create_execution_handler.test.ts | 1 + .../task_runner/create_execution_handler.ts | 1 + .../server/task_runner/task_runner.test.ts | 231 ++++++++++++++---- .../alerts/server/task_runner/task_runner.ts | 115 ++++++--- .../plugins/event_log/generated/mappings.json | 8 + x-pack/plugins/event_log/generated/schemas.ts | 4 +- x-pack/plugins/event_log/scripts/mappings.js | 6 + .../event_log/server/event_logger.test.ts | 6 +- .../plugins/event_log/server/event_logger.ts | 7 +- .../tests/alerting/event_log.ts | 84 +++++++ .../tests/alerting/index.ts | 1 + .../spaces_only/tests/alerting/event_log.ts | 106 +++++--- 13 files changed, 442 insertions(+), 132 deletions(-) create mode 100644 x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts diff --git a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts index 661fb75f81c00..01790b2a4a0c0 100644 --- a/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts +++ b/x-pack/plugins/alerts/server/alert_instance/alert_instance.ts @@ -51,6 +51,10 @@ export class AlertInstance< return false; } + getLastScheduledActions() { + return this.meta.lastScheduledActions; + } + getScheduledActionOptions() { return this.scheduledExecutionOptions; } diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts index 2f0754d34492f..ed73fec24db26 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.test.ts @@ -113,6 +113,7 @@ test('enqueues execution per selected action', async () => { }, "kibana": Object { "alerting": Object { + "action_group_id": "default", "instance_id": "2", }, "saved_objects": Array [ diff --git a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts index 21e642d228b4d..f49310c42c247 100644 --- a/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts +++ b/x-pack/plugins/alerts/server/task_runner/create_execution_handler.ts @@ -116,6 +116,7 @@ export function createExecutionHandler({ kibana: { alerting: { instance_id: alertInstanceId, + action_group_id: actionGroup, }, saved_objects: [ { rel: SAVED_OBJECT_REL_PRIMARY, type: 'alert', id: alertId, ...namespace }, 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 4d0d69010914e..859b6ec4362ce 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 @@ -184,11 +184,15 @@ describe('Task Runner', () => { expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); expect(eventLogger.logEvent.mock.calls[0][0]).toMatchInlineSnapshot(` Object { + "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { "action": "execute", "outcome": "success", }, "kibana": Object { + "alerting": Object { + "status": "ok", + }, "saved_objects": Array [ Object { "id": "1", @@ -249,29 +253,13 @@ describe('Task Runner', () => { const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; expect(eventLogger.logEvent).toHaveBeenCalledTimes(4); - expect(eventLogger.logEvent).toHaveBeenCalledWith({ - event: { - action: 'execute', - outcome: 'success', - }, - kibana: { - saved_objects: [ - { - id: '1', - namespace: undefined, - rel: 'primary', - type: 'alert', - }, - ], - }, - message: "alert executed: test:1: 'alert-name'", - }); - expect(eventLogger.logEvent).toHaveBeenCalledWith({ + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(1, { event: { action: 'new-instance', }, kibana: { alerting: { + action_group_id: 'default', instance_id: '1', }, saved_objects: [ @@ -285,7 +273,7 @@ describe('Task Runner', () => { }, message: "test:1: 'alert-name' created new instance: '1'", }); - expect(eventLogger.logEvent).toHaveBeenCalledWith({ + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(2, { event: { action: 'active-instance', }, @@ -305,13 +293,14 @@ describe('Task Runner', () => { }, message: "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", }); - expect(eventLogger.logEvent).toHaveBeenCalledWith({ + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(3, { event: { action: 'execute-action', }, kibana: { alerting: { instance_id: '1', + action_group_id: 'default', }, saved_objects: [ { @@ -330,6 +319,27 @@ describe('Task Runner', () => { message: "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", }); + expect(eventLogger.logEvent).toHaveBeenNthCalledWith(4, { + '@timestamp': '1970-01-01T00:00:00.000Z', + event: { + action: 'execute', + outcome: 'success', + }, + kibana: { + alerting: { + status: 'active', + }, + saved_objects: [ + { + id: '1', + namespace: undefined, + rel: 'primary', + type: 'alert', + }, + ], + }, + message: "alert executed: test:1: 'alert-name'", + }); }); test('includes the apiKey in the request used to initialize the actionsClient', async () => { @@ -402,10 +412,13 @@ describe('Task Runner', () => { Array [ Object { "event": Object { - "action": "execute", - "outcome": "success", + "action": "new-instance", }, "kibana": Object { + "alerting": Object { + "action_group_id": "default", + "instance_id": "1", + }, "saved_objects": Array [ Object { "id": "1", @@ -415,17 +428,17 @@ describe('Task Runner', () => { }, ], }, - "message": "alert executed: test:1: 'alert-name'", + "message": "test:1: 'alert-name' created new instance: '1'", }, ], Array [ Object { "event": Object { - "action": "new-instance", + "action": "active-instance", }, "kibana": Object { "alerting": Object { - "action_group_id": undefined, + "action_group_id": "default", "instance_id": "1", }, "saved_objects": Array [ @@ -437,13 +450,13 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' created new instance: '1'", + "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", }, ], Array [ Object { "event": Object { - "action": "active-instance", + "action": "execute-action", }, "kibana": Object { "alerting": Object { @@ -457,19 +470,26 @@ describe('Task Runner', () => { "rel": "primary", "type": "alert", }, + Object { + "id": "1", + "namespace": undefined, + "type": "action", + }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", }, ], Array [ Object { + "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { - "action": "execute-action", + "action": "execute", + "outcome": "success", }, "kibana": Object { "alerting": Object { - "instance_id": "1", + "status": "active", }, "saved_objects": Array [ Object { @@ -478,14 +498,9 @@ describe('Task Runner', () => { "rel": "primary", "type": "alert", }, - Object { - "id": "1", - "namespace": undefined, - "type": "action", - }, ], }, - "message": "alert: test:1: 'alert-name' instanceId: '1' scheduled actionGroup: 'default' action: action:1", + "message": "alert executed: test:1: 'alert-name'", }, ], ] @@ -498,6 +513,7 @@ describe('Task Runner', () => { executorServices.alertInstanceFactory('1').scheduleActions('default'); } ); + const date = new Date().toISOString(); const taskRunner = new TaskRunner( alertType, { @@ -505,8 +521,14 @@ describe('Task Runner', () => { state: { ...mockedTaskInstance.state, alertInstances: { - '1': { meta: {}, state: { bar: false } }, - '2': { meta: {}, state: { bar: false } }, + '1': { + meta: { lastScheduledActions: { group: 'default', date } }, + state: { bar: false }, + }, + '2': { + meta: { lastScheduledActions: { group: 'default', date } }, + state: { bar: false }, + }, }, }, }, @@ -545,10 +567,13 @@ describe('Task Runner', () => { Array [ Object { "event": Object { - "action": "execute", - "outcome": "success", + "action": "resolved-instance", }, "kibana": Object { + "alerting": Object { + "action_group_id": "default", + "instance_id": "2", + }, "saved_objects": Array [ Object { "id": "1", @@ -558,18 +583,18 @@ describe('Task Runner', () => { }, ], }, - "message": "alert executed: test:1: 'alert-name'", + "message": "test:1: 'alert-name' resolved instance: '2'", }, ], Array [ Object { "event": Object { - "action": "resolved-instance", + "action": "active-instance", }, "kibana": Object { "alerting": Object { - "action_group_id": undefined, - "instance_id": "2", + "action_group_id": "default", + "instance_id": "1", }, "saved_objects": Array [ Object { @@ -580,18 +605,19 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' resolved instance: '2'", + "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", }, ], Array [ Object { + "@timestamp": "1970-01-01T00:00:00.000Z", "event": Object { - "action": "active-instance", + "action": "execute", + "outcome": "success", }, "kibana": Object { "alerting": Object { - "action_group_id": "default", - "instance_id": "1", + "status": "active", }, "saved_objects": Array [ Object { @@ -602,7 +628,7 @@ describe('Task Runner', () => { }, ], }, - "message": "test:1: 'alert-name' active instance: '1' in actionGroup: 'default'", + "message": "alert executed: test:1: 'alert-name'", }, ], ] @@ -787,14 +813,19 @@ describe('Task Runner', () => { Array [ Array [ Object { + "@timestamp": "1970-01-01T00:00:00.000Z", "error": Object { "message": "OMG", }, "event": Object { "action": "execute", "outcome": "failure", + "reason": "execute", }, "kibana": Object { + "alerting": Object { + "status": "error", + }, "saved_objects": Array [ Object { "id": "1", @@ -834,6 +865,40 @@ describe('Task Runner', () => { "state": Object {}, } `); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "error": Object { + "message": "OMG", + }, + "event": Object { + "action": "execute", + "outcome": "failure", + "reason": "decrypt", + }, + "kibana": Object { + "alerting": Object { + "status": "error", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: execution failed", + }, + ], + ] + `); }); test('recovers gracefully when the Alert Task Runner throws an exception when getting internal Services', async () => { @@ -867,6 +932,40 @@ describe('Task Runner', () => { "state": Object {}, } `); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "error": Object { + "message": "OMG", + }, + "event": Object { + "action": "execute", + "outcome": "failure", + "reason": "unknown", + }, + "kibana": Object { + "alerting": Object { + "status": "error", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: execution failed", + }, + ], + ] + `); }); test('recovers gracefully when the Alert Task Runner throws an exception when fetching attributes', async () => { @@ -899,6 +998,40 @@ describe('Task Runner', () => { "state": Object {}, } `); + + const eventLogger = taskRunnerFactoryInitializerParams.eventLogger; + expect(eventLogger.logEvent).toHaveBeenCalledTimes(1); + expect(eventLogger.logEvent.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + Object { + "@timestamp": "1970-01-01T00:00:00.000Z", + "error": Object { + "message": "OMG", + }, + "event": Object { + "action": "execute", + "outcome": "failure", + "reason": "read", + }, + "kibana": Object { + "alerting": Object { + "status": "error", + }, + "saved_objects": Array [ + Object { + "id": "1", + "namespace": undefined, + "rel": "primary", + "type": "alert", + }, + ], + }, + "message": "test:1: execution failed", + }, + ], + ] + `); }); test('recovers gracefully when the Runner of a legacy Alert task which has no schedule throws an exception when fetching attributes', async () => { 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 86bf7006e8d09..5bccf5c497a60 100644 --- a/x-pack/plugins/alerts/server/task_runner/task_runner.ts +++ b/x-pack/plugins/alerts/server/task_runner/task_runner.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import type { PublicMethodsOf } from '@kbn/utility-types'; -import { Dictionary, pickBy, mapValues, without } from 'lodash'; +import { Dictionary, pickBy, mapValues, without, cloneDeep } from 'lodash'; import { Logger, KibanaRequest } from '../../../../../src/core/server'; import { TaskRunnerContext } from './task_runner_factory'; import { ConcreteTaskInstance, throwUnrecoverableError } from '../../../task_manager/server'; @@ -40,6 +40,8 @@ import { partiallyUpdateAlert } from '../saved_objects'; const FALLBACK_RETRY_INTERVAL = '5m'; +type Event = Exclude; + interface AlertTaskRunResult { state: AlertTaskState; schedule: IntervalSchedule | undefined; @@ -153,7 +155,8 @@ export class TaskRunner { alert: SanitizedAlert, params: AlertExecutorOptions['params'], executionHandler: ReturnType, - spaceId: string + spaceId: string, + event: Event ): Promise { const { throttle, muteAll, mutedInstanceIds, name, tags, createdBy, updatedBy } = alert; const { @@ -166,24 +169,10 @@ export class TaskRunner { alertRawInstances, (rawAlertInstance) => new AlertInstance(rawAlertInstance) ); + const originalAlertInstances = cloneDeep(alertInstances); - const originalAlertInstanceIds = Object.keys(alertInstances); const eventLogger = this.context.eventLogger; const alertLabel = `${this.alertType.id}:${alertId}: '${name}'`; - const event: IEvent = { - event: { action: EVENT_LOG_ACTIONS.execute }, - kibana: { - saved_objects: [ - { - rel: SAVED_OBJECT_REL_PRIMARY, - type: 'alert', - id: alertId, - namespace, - }, - ], - }, - }; - eventLogger.startTiming(event); let updatedAlertTypeState: void | Record; try { @@ -205,21 +194,17 @@ export class TaskRunner { updatedBy, }); } catch (err) { - eventLogger.stopTiming(event); event.message = `alert execution failure: ${alertLabel}`; event.error = event.error || {}; event.error.message = err.message; event.event = event.event || {}; event.event.outcome = 'failure'; - eventLogger.logEvent(event); throw new ErrorWithReason(AlertExecutionStatusErrorReasons.Execute, err); } - eventLogger.stopTiming(event); event.message = `alert executed: ${alertLabel}`; event.event = event.event || {}; event.event.outcome = 'success'; - eventLogger.logEvent(event); // Cleanup alert instances that are no longer scheduling actions to avoid over populating the alertInstances object const instancesWithScheduledActions = pickBy(alertInstances, (alertInstance: AlertInstance) => @@ -227,7 +212,7 @@ export class TaskRunner { ); generateNewAndResolvedInstanceEvents({ eventLogger, - originalAlertInstanceIds, + originalAlertInstances, currentAlertInstances: instancesWithScheduledActions, alertId, alertLabel, @@ -261,7 +246,8 @@ export class TaskRunner { async validateAndExecuteAlert( services: Services, apiKey: RawAlert['apiKey'], - alert: SanitizedAlert + alert: SanitizedAlert, + event: Event ) { const { params: { alertId, spaceId }, @@ -278,10 +264,17 @@ export class TaskRunner { alert.actions, alert.params ); - return this.executeAlertInstances(services, alert, validatedParams, executionHandler, spaceId); + return this.executeAlertInstances( + services, + alert, + validatedParams, + executionHandler, + spaceId, + event + ); } - async loadAlertAttributesAndRun(): Promise> { + async loadAlertAttributesAndRun(event: Event): Promise> { const { params: { alertId, spaceId }, } = this.taskInstance; @@ -304,7 +297,7 @@ export class TaskRunner { return { state: await promiseResult( - this.validateAndExecuteAlert(services, apiKey, alert) + this.validateAndExecuteAlert(services, apiKey, alert, event) ), schedule: asOk( // fetch the alert again to ensure we return the correct schedule as it may have @@ -322,18 +315,65 @@ export class TaskRunner { schedule: taskSchedule, } = this.taskInstance; - const { state, schedule } = await errorAsAlertTaskRunResult(this.loadAlertAttributesAndRun()); - const namespace = spaceId === 'default' ? undefined : spaceId; + const namespace = this.context.spaceIdToNamespace(spaceId); + const eventLogger = this.context.eventLogger; + const event: IEvent = { + // explicitly set execute timestamp so it will be before other events + // generated here (new-instance, schedule-action, etc) + '@timestamp': new Date().toISOString(), + event: { action: EVENT_LOG_ACTIONS.execute }, + kibana: { + saved_objects: [ + { + rel: SAVED_OBJECT_REL_PRIMARY, + type: 'alert', + id: alertId, + namespace, + }, + ], + }, + }; + eventLogger.startTiming(event); + + const { state, schedule } = await errorAsAlertTaskRunResult( + this.loadAlertAttributesAndRun(event) + ); const executionStatus: AlertExecutionStatus = map( state, (alertTaskState: AlertTaskState) => executionStatusFromState(alertTaskState), (err: Error) => executionStatusFromError(err) ); + + // set the executionStatus date to same as event, if it's set + if (event.event?.start) { + executionStatus.lastExecutionDate = new Date(event.event.start); + } + this.logger.debug( `alertExecutionStatus for ${this.alertType.id}:${alertId}: ${JSON.stringify(executionStatus)}` ); + eventLogger.stopTiming(event); + event.kibana = event.kibana || {}; + event.kibana.alerting = event.kibana.alerting || {}; + event.kibana.alerting.status = executionStatus.status; + + // if executionStatus indicates an error, fill in fields in + // event from it + if (executionStatus.error) { + event.event = event.event || {}; + event.event.reason = executionStatus.error?.reason || 'unknown'; + event.event.outcome = 'failure'; + event.error = event.error || {}; + event.error.message = event.error.message || executionStatus.error.message; + if (!event.message) { + event.message = `${this.alertType.id}:${alertId}: execution failed`; + } + } + + eventLogger.logEvent(event); + const client = this.context.internalSavedObjectsRepository; const attributes = { executionStatus: alertExecutionStatusToRaw(executionStatus), @@ -381,7 +421,7 @@ export class TaskRunner { interface GenerateNewAndResolvedInstanceEventsParams { eventLogger: IEventLogger; - originalAlertInstanceIds: string[]; + originalAlertInstances: Dictionary; currentAlertInstances: Dictionary; alertId: string; alertLabel: string; @@ -389,26 +429,23 @@ interface GenerateNewAndResolvedInstanceEventsParams { } function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInstanceEventsParams) { - const { - eventLogger, - alertId, - namespace, - currentAlertInstances, - originalAlertInstanceIds, - } = params; + const { eventLogger, alertId, namespace, currentAlertInstances, originalAlertInstances } = params; + const originalAlertInstanceIds = Object.keys(originalAlertInstances); const currentAlertInstanceIds = Object.keys(currentAlertInstances); const newIds = without(currentAlertInstanceIds, ...originalAlertInstanceIds); const resolvedIds = without(originalAlertInstanceIds, ...currentAlertInstanceIds); for (const id of resolvedIds) { + const actionGroup = originalAlertInstances[id].getLastScheduledActions()?.group; const message = `${params.alertLabel} resolved instance: '${id}'`; - logInstanceEvent(id, EVENT_LOG_ACTIONS.resolvedInstance, message); + logInstanceEvent(id, EVENT_LOG_ACTIONS.resolvedInstance, message, actionGroup); } for (const id of newIds) { + const actionGroup = currentAlertInstances[id].getScheduledActionOptions()?.actionGroup; const message = `${params.alertLabel} created new instance: '${id}'`; - logInstanceEvent(id, EVENT_LOG_ACTIONS.newInstance, message); + logInstanceEvent(id, EVENT_LOG_ACTIONS.newInstance, message, actionGroup); } for (const id of currentAlertInstanceIds) { @@ -425,7 +462,7 @@ function generateNewAndResolvedInstanceEvents(params: GenerateNewAndResolvedInst kibana: { alerting: { instance_id: instanceId, - action_group_id: group, + ...(group ? { action_group_id: group } : {}), }, saved_objects: [ { diff --git a/x-pack/plugins/event_log/generated/mappings.json b/x-pack/plugins/event_log/generated/mappings.json index 5c7eb50117d9b..3131235ebcfbe 100644 --- a/x-pack/plugins/event_log/generated/mappings.json +++ b/x-pack/plugins/event_log/generated/mappings.json @@ -45,6 +45,10 @@ "outcome": { "ignore_above": 1024, "type": "keyword" + }, + "reason": { + "ignore_above": 1024, + "type": "keyword" } } }, @@ -85,6 +89,10 @@ "action_group_id": { "type": "keyword", "ignore_above": 1024 + }, + "status": { + "type": "keyword", + "ignore_above": 1024 } } }, diff --git a/x-pack/plugins/event_log/generated/schemas.ts b/x-pack/plugins/event_log/generated/schemas.ts index 3dbb43b15350f..d2024ea8ed14a 100644 --- a/x-pack/plugins/event_log/generated/schemas.ts +++ b/x-pack/plugins/event_log/generated/schemas.ts @@ -18,7 +18,7 @@ type DeepPartial = { [P in keyof T]?: T[P] extends Array ? Array> : DeepPartial; }; -export const ECS_VERSION = '1.5.0'; +export const ECS_VERSION = '1.6.0'; // types and config-schema describing the es structures export type IValidatedEvent = TypeOf; @@ -42,6 +42,7 @@ export const EventSchema = schema.maybe( duration: ecsNumber(), end: ecsDate(), outcome: ecsString(), + reason: ecsString(), }) ), error: schema.maybe( @@ -61,6 +62,7 @@ export const EventSchema = schema.maybe( schema.object({ instance_id: ecsString(), action_group_id: ecsString(), + status: ecsString(), }) ), saved_objects: schema.maybe( diff --git a/x-pack/plugins/event_log/scripts/mappings.js b/x-pack/plugins/event_log/scripts/mappings.js index c9af2b0aa57fb..bd05f84d4e2b8 100644 --- a/x-pack/plugins/event_log/scripts/mappings.js +++ b/x-pack/plugins/event_log/scripts/mappings.js @@ -22,6 +22,10 @@ exports.EcsKibanaExtensionsMappings = { type: 'keyword', ignore_above: 1024, }, + status: { + type: 'keyword', + ignore_above: 1024, + }, }, }, // array of saved object references, for "linking" via search @@ -63,11 +67,13 @@ exports.EcsEventLogProperties = [ 'event.duration', 'event.end', 'event.outcome', // optional, but one of failure, success, unknown + 'event.reason', 'error.message', 'user.name', 'kibana.server_uuid', 'kibana.alerting.instance_id', 'kibana.alerting.action_group_id', + 'kibana.alerting.status', 'kibana.saved_objects.rel', 'kibana.saved_objects.namespace', 'kibana.saved_objects.id', diff --git a/x-pack/plugins/event_log/server/event_logger.test.ts b/x-pack/plugins/event_log/server/event_logger.test.ts index 0ab3071f70efa..ea699af45ccd2 100644 --- a/x-pack/plugins/event_log/server/event_logger.test.ts +++ b/x-pack/plugins/event_log/server/event_logger.test.ts @@ -102,16 +102,16 @@ describe('EventLogger', () => { event: { provider: 'test-provider', action: 'a' }, }); - const ignoredTimestamp = '1999-01-01T00:00:00Z'; + const respectedTimestamp = '2999-01-01T00:00:00.000Z'; eventLogger.logEvent({ - '@timestamp': ignoredTimestamp, + '@timestamp': respectedTimestamp, event: { action: 'b', }, }); const event = await waitForLogEvent(systemLogger); - expect(event!['@timestamp']).not.toEqual(ignoredTimestamp); + expect(event!['@timestamp']).toEqual(respectedTimestamp); expect(event?.event?.action).toEqual('b'); }); diff --git a/x-pack/plugins/event_log/server/event_logger.ts b/x-pack/plugins/event_log/server/event_logger.ts index 8730870f9620b..658d90d809652 100644 --- a/x-pack/plugins/event_log/server/event_logger.ts +++ b/x-pack/plugins/event_log/server/event_logger.ts @@ -72,7 +72,6 @@ export class EventLogger implements IEventLogger { const event: IEvent = {}; const fixedProperties = { - '@timestamp': new Date().toISOString(), ecs: { version: ECS_VERSION, }, @@ -81,8 +80,12 @@ export class EventLogger implements IEventLogger { }, }; + const defaultProperties = { + '@timestamp': new Date().toISOString(), + }; + // merge the initial properties and event properties - merge(event, this.initialProperties, eventProperties, fixedProperties); + merge(event, defaultProperties, this.initialProperties, eventProperties, fixedProperties); let validatedEvent: IValidatedEvent; try { diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts new file mode 100644 index 0000000000000..385d8bfca4a9a --- /dev/null +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/event_log.ts @@ -0,0 +1,84 @@ +/* + * 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 { Spaces } from '../../scenarios'; +import { getUrlPrefix, getTestAlertData, ObjectRemover, getEventLog } from '../../../common/lib'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; +import { validateEvent } from '../../../spaces_only/tests/alerting/event_log'; + +// eslint-disable-next-line import/no-default-export +export default function eventLogTests({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const retry = getService('retry'); + + describe('eventLog', () => { + const objectRemover = new ObjectRemover(supertest); + + after(() => objectRemover.removeAll()); + + it('should generate events for alert decrypt errors', async () => { + const spaceId = Spaces[0].id; + const response = await supertest + .post(`${getUrlPrefix(spaceId)}/api/alerts/alert`) + .set('kbn-xsrf', 'foo') + .send( + getTestAlertData({ + alertTypeId: 'test.noop', + schedule: { interval: '1s' }, + throttle: null, + }) + ); + + expect(response.status).to.eql(200); + const alertId = response.body.id; + objectRemover.add(spaceId, alertId, 'alert', 'alerts'); + + // break AAD + await supertest + .put(`${getUrlPrefix(spaceId)}/api/alerts_fixture/saved_object/alert/${alertId}`) + .set('kbn-xsrf', 'foo') + .send({ + attributes: { + name: 'bar', + }, + }) + .expect(200); + + const events = await retry.try(async () => { + // there can be a successful execute before the error one + const someEvents = await getEventLog({ + getService, + spaceId, + type: 'alert', + id: alertId, + provider: 'alerting', + actions: ['execute'], + }); + const errorEvents = someEvents.filter( + (event) => event?.kibana?.alerting?.status === 'error' + ); + if (errorEvents.length === 0) { + throw new Error('no execute/error events yet'); + } + return errorEvents; + }); + + const event = events[0]; + expect(event).to.be.ok(); + + validateEvent(event, { + spaceId, + savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], + outcome: 'failure', + message: `test.noop:${alertId}: execution failed`, + errorMessage: 'Unable to decrypt attribute "apiKey"', + status: 'error', + reason: 'decrypt', + }); + }); + }); +} 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 1fbee9e18fdaa..4f8525cfcf683 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 @@ -26,6 +26,7 @@ export default function alertingTests({ loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./update_api_key')); loadTestFile(require.resolve('./alerts')); + loadTestFile(require.resolve('./event_log')); // note that this test will destroy existing spaces loadTestFile(require.resolve('./rbac_legacy')); 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 dbf8eb162fca7..937045b6218c6 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 @@ -107,6 +107,8 @@ export default function eventLogTests({ getService }: FtrProviderContext) { expect(resolvedInstanceTimes[0] > newInstanceTimes[0]).to.be(true); // validate each event + let executeCount = 0; + const executeStatuses = ['ok', 'active', 'active']; for (const event of events) { switch (event?.event?.action) { case 'execute': @@ -115,6 +117,7 @@ export default function eventLogTests({ getService }: FtrProviderContext) { savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], outcome: 'success', message: `alert executed: test.patternFiring:${alertId}: 'abc'`, + status: executeStatuses[executeCount++], }); break; case 'execute-action': @@ -125,6 +128,8 @@ export default function eventLogTests({ getService }: FtrProviderContext) { { type: 'action', id: createdAction.id }, ], message: `alert: test.patternFiring:${alertId}: 'abc' instanceId: 'instance' scheduled actionGroup: 'default' action: test.noop:${createdAction.id}`, + instanceId: 'instance', + actionGroupId: 'default', }); break; case 'new-instance': @@ -147,6 +152,8 @@ export default function eventLogTests({ getService }: FtrProviderContext) { spaceId: Spaces.space1.id, savedObjects: [{ type: 'alert', id: alertId, rel: 'primary' }], message: `test.patternFiring:${alertId}: 'abc' ${subMessage}`, + instanceId: 'instance', + actionGroupId: 'default', }); } }); @@ -187,60 +194,83 @@ export default function eventLogTests({ getService }: FtrProviderContext) { outcome: 'failure', message: `alert execution failure: test.throw:${alertId}: 'abc'`, errorMessage: 'this alert is intended to fail', + status: 'error', + reason: 'execute', }); }); }); +} + +interface SavedObject { + type: string; + id: string; + rel?: string; +} + +interface ValidateEventLogParams { + spaceId: string; + savedObjects: SavedObject[]; + outcome?: string; + message: string; + errorMessage?: string; + status?: string; + actionGroupId?: string; + instanceId?: string; + reason?: string; +} + +export function validateEvent(event: IValidatedEvent, params: ValidateEventLogParams): void { + const { spaceId, savedObjects, outcome, message, errorMessage } = params; + const { status, actionGroupId, instanceId, reason } = params; - interface SavedObject { - type: string; - id: string; - rel?: string; + if (status) { + expect(event?.kibana?.alerting?.status).to.be(status); } - interface ValidateEventLogParams { - spaceId: string; - savedObjects: SavedObject[]; - outcome?: string; - message: string; - errorMessage?: string; + if (actionGroupId) { + expect(event?.kibana?.alerting?.action_group_id).to.be(actionGroupId); } - function validateEvent(event: IValidatedEvent, params: ValidateEventLogParams): void { - const { spaceId, savedObjects, outcome, message, errorMessage } = params; + if (instanceId) { + expect(event?.kibana?.alerting?.instance_id).to.be(instanceId); + } - const duration = event?.event?.duration; - const eventStart = Date.parse(event?.event?.start || 'undefined'); - const eventEnd = Date.parse(event?.event?.end || 'undefined'); - const dateNow = Date.now(); + if (reason) { + expect(event?.event?.reason).to.be(reason); + } - if (duration !== undefined) { - expect(typeof duration).to.be('number'); - expect(eventStart).to.be.ok(); - expect(eventEnd).to.be.ok(); + const duration = event?.event?.duration; + const eventStart = Date.parse(event?.event?.start || 'undefined'); + const eventEnd = Date.parse(event?.event?.end || 'undefined'); + const dateNow = Date.now(); - const durationDiff = Math.abs( - Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart) - ); + if (duration !== undefined) { + expect(typeof duration).to.be('number'); + expect(eventStart).to.be.ok(); + expect(eventEnd).to.be.ok(); - // account for rounding errors - expect(durationDiff < 1).to.equal(true); - expect(eventStart <= eventEnd).to.equal(true); - expect(eventEnd <= dateNow).to.equal(true); - } + const durationDiff = Math.abs( + Math.round(duration! / NANOS_IN_MILLIS) - (eventEnd - eventStart) + ); - expect(event?.event?.outcome).to.equal(outcome); + // account for rounding errors + expect(durationDiff < 1).to.equal(true); + expect(eventStart <= eventEnd).to.equal(true); + expect(eventEnd <= dateNow).to.equal(true); + } - for (const savedObject of savedObjects) { - expect( - isSavedObjectInEvent(event, spaceId, savedObject.type, savedObject.id, savedObject.rel) - ).to.be(true); - } + expect(event?.event?.outcome).to.equal(outcome); - expect(event?.message).to.eql(message); + for (const savedObject of savedObjects) { + expect( + isSavedObjectInEvent(event, spaceId, savedObject.type, savedObject.id, savedObject.rel) + ).to.be(true); + } - if (errorMessage) { - expect(event?.error?.message).to.eql(errorMessage); - } + expect(event?.message).to.eql(message); + + if (errorMessage) { + expect(event?.error?.message).to.eql(errorMessage); } } From 55519665d64d58dd5bcc4773e609715c6b951cb7 Mon Sep 17 00:00:00 2001 From: Maja Grubic Date: Thu, 12 Nov 2020 14:38:07 +0000 Subject: [PATCH 9/9] [Advanced Settings] Introducing telemetry (#82860) * [Advaned Settings] Introducing telemetry * Publishing doc changes * Move metric tracking to onSave method * Adding deprecated warning * Updating docs Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- ...ana-plugin-core-public.uisettingsparams.md | 1 + ...gin-core-public.uisettingsparams.metric.md | 21 +++++++++++++++++++ ...ana-plugin-core-server.uisettingsparams.md | 1 + ...gin-core-server.uisettingsparams.metric.md | 21 +++++++++++++++++++ src/core/public/public.api.md | 6 ++++++ src/core/server/server.api.md | 6 ++++++ src/core/types/ui_settings.ts | 10 +++++++++ src/plugins/advanced_settings/kibana.json | 2 +- .../management_app/advanced_settings.tsx | 4 ++++ .../management_app/components/form/form.tsx | 9 +++++++- .../management_app/lib/to_editable_config.ts | 1 + .../mount_management_section.tsx | 6 +++++- .../public/management_app/types.ts | 5 +++++ .../advanced_settings/public/plugin.ts | 12 +++++++++-- src/plugins/advanced_settings/public/types.ts | 2 ++ src/plugins/data/server/server.api.md | 1 + src/plugins/discover/server/ui_settings.ts | 7 ++++++- 17 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 docs/development/core/public/kibana-plugin-core-public.uisettingsparams.metric.md create mode 100644 docs/development/core/server/kibana-plugin-core-server.uisettingsparams.metric.md diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md index e7facb4a109cd..4a9fc940c596f 100644 --- a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.md @@ -19,6 +19,7 @@ export interface UiSettingsParams | [category](./kibana-plugin-core-public.uisettingsparams.category.md) | string[] | used to group the configured setting in the UI | | [deprecation](./kibana-plugin-core-public.uisettingsparams.deprecation.md) | DeprecationSettings | optional deprecation information. Used to generate a deprecation warning. | | [description](./kibana-plugin-core-public.uisettingsparams.description.md) | string | description provided to a user in UI | +| [metric](./kibana-plugin-core-public.uisettingsparams.metric.md) | {
type: UiStatsMetricType;
name: string;
} | Metric to track once this property changes | | [name](./kibana-plugin-core-public.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-core-public.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | | [options](./kibana-plugin-core-public.uisettingsparams.options.md) | string[] | array of permitted values for this setting | diff --git a/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.metric.md b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.metric.md new file mode 100644 index 0000000000000..0855cfd77a46b --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.uisettingsparams.metric.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [UiSettingsParams](./kibana-plugin-core-public.uisettingsparams.md) > [metric](./kibana-plugin-core-public.uisettingsparams.metric.md) + +## UiSettingsParams.metric property + +> Warning: This API is now obsolete. +> +> Temporary measure until https://github.com/elastic/kibana/issues/83084 is in place +> + +Metric to track once this property changes + +Signature: + +```typescript +metric?: { + type: UiStatsMetricType; + name: string; + }; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md index f134decb5102b..7bcb996e98e16 100644 --- a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.md @@ -19,6 +19,7 @@ export interface UiSettingsParams | [category](./kibana-plugin-core-server.uisettingsparams.category.md) | string[] | used to group the configured setting in the UI | | [deprecation](./kibana-plugin-core-server.uisettingsparams.deprecation.md) | DeprecationSettings | optional deprecation information. Used to generate a deprecation warning. | | [description](./kibana-plugin-core-server.uisettingsparams.description.md) | string | description provided to a user in UI | +| [metric](./kibana-plugin-core-server.uisettingsparams.metric.md) | {
type: UiStatsMetricType;
name: string;
} | Metric to track once this property changes | | [name](./kibana-plugin-core-server.uisettingsparams.name.md) | string | title in the UI | | [optionLabels](./kibana-plugin-core-server.uisettingsparams.optionlabels.md) | Record<string, string> | text labels for 'select' type UI element | | [options](./kibana-plugin-core-server.uisettingsparams.options.md) | string[] | array of permitted values for this setting | diff --git a/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.metric.md b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.metric.md new file mode 100644 index 0000000000000..4d54bf9ae472b --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.uisettingsparams.metric.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [UiSettingsParams](./kibana-plugin-core-server.uisettingsparams.md) > [metric](./kibana-plugin-core-server.uisettingsparams.metric.md) + +## UiSettingsParams.metric property + +> Warning: This API is now obsolete. +> +> Temporary measure until https://github.com/elastic/kibana/issues/83084 is in place +> + +Metric to track once this property changes + +Signature: + +```typescript +metric?: { + type: UiStatsMetricType; + name: string; + }; +``` diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 781a50f849e24..c8add5a8ddf58 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -38,6 +38,7 @@ import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; +import { UiStatsMetricType } from '@kbn/analytics'; import { UnregisterCallback } from 'history'; import { UserProvidedValues as UserProvidedValues_2 } from 'src/core/server/types'; @@ -1362,6 +1363,11 @@ export interface UiSettingsParams { // Warning: (ae-forgotten-export) The symbol "DeprecationSettings" needs to be exported by the entry point index.d.ts deprecation?: DeprecationSettings; description?: string; + // @deprecated + metric?: { + type: UiStatsMetricType; + name: string; + }; name?: string; optionLabels?: Record; options?: string[]; diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 88d7fecbcf502..a03e5ec9acd27 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -160,6 +160,7 @@ import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; import { TransportRequestPromise } from '@elastic/elasticsearch/lib/Transport'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; +import { UiStatsMetricType } from '@kbn/analytics'; import { UpdateDocumentByQueryParams } from 'elasticsearch'; import { UpdateDocumentParams } from 'elasticsearch'; import { URL } from 'url'; @@ -2746,6 +2747,11 @@ export interface UiSettingsParams { category?: string[]; deprecation?: DeprecationSettings; description?: string; + // @deprecated + metric?: { + type: UiStatsMetricType; + name: string; + }; name?: string; optionLabels?: Record; options?: string[]; diff --git a/src/core/types/ui_settings.ts b/src/core/types/ui_settings.ts index ed1076b571960..0b7a8e1efd9df 100644 --- a/src/core/types/ui_settings.ts +++ b/src/core/types/ui_settings.ts @@ -17,6 +17,7 @@ * under the License. */ import { Type } from '@kbn/config-schema'; +import { UiStatsMetricType } from '@kbn/analytics'; /** * UI element type to represent the settings. @@ -80,6 +81,15 @@ export interface UiSettingsParams { * Used to validate value on write and read. */ schema: Type; + /** + * Metric to track once this property changes + * @deprecated + * Temporary measure until https://github.com/elastic/kibana/issues/83084 is in place + */ + metric?: { + type: UiStatsMetricType; + name: string; + }; } /** diff --git a/src/plugins/advanced_settings/kibana.json b/src/plugins/advanced_settings/kibana.json index 0e49fe17089f0..df0d31a904c59 100644 --- a/src/plugins/advanced_settings/kibana.json +++ b/src/plugins/advanced_settings/kibana.json @@ -4,6 +4,6 @@ "server": true, "ui": true, "requiredPlugins": ["management"], - "optionalPlugins": ["home"], + "optionalPlugins": ["home", "usageCollection"], "requiredBundles": ["kibanaReact", "home"] } diff --git a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx index afdd90959eabd..bbc27ca025ede 100644 --- a/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx +++ b/src/plugins/advanced_settings/public/management_app/advanced_settings.tsx @@ -22,6 +22,7 @@ import { Subscription } from 'rxjs'; import { Comparators, EuiFlexGroup, EuiFlexItem, EuiSpacer, Query } from '@elastic/eui'; import { useParams } from 'react-router-dom'; +import { UiStatsMetricType } from '@kbn/analytics'; import { CallOuts } from './components/call_outs'; import { Search } from './components/search'; import { Form } from './components/form'; @@ -39,6 +40,7 @@ interface AdvancedSettingsProps { dockLinks: DocLinksStart['links']; toasts: ToastsStart; componentRegistry: ComponentRegistry['start']; + trackUiMetric?: (metricType: UiStatsMetricType, eventName: string | string[]) => void; } interface AdvancedSettingsComponentProps extends AdvancedSettingsProps { @@ -241,6 +243,7 @@ export class AdvancedSettingsComponent extends Component< enableSaving={this.props.enableSaving} dockLinks={this.props.dockLinks} toasts={this.props.toasts} + trackUiMetric={this.props.trackUiMetric} /> { dockLinks={props.dockLinks} toasts={props.toasts} componentRegistry={props.componentRegistry} + trackUiMetric={props.trackUiMetric} /> ); }; diff --git a/src/plugins/advanced_settings/public/management_app/components/form/form.tsx b/src/plugins/advanced_settings/public/management_app/components/form/form.tsx index d243d85e12a66..c30768a262056 100644 --- a/src/plugins/advanced_settings/public/management_app/components/form/form.tsx +++ b/src/plugins/advanced_settings/public/management_app/components/form/form.tsx @@ -36,6 +36,7 @@ import { import { FormattedMessage } from '@kbn/i18n/react'; import { isEmpty } from 'lodash'; import { i18n } from '@kbn/i18n'; +import { UiStatsMetricType } from '@kbn/analytics'; import { toMountPoint } from '../../../../../kibana_react/public'; import { DocLinksStart, ToastsStart } from '../../../../../../core/public'; @@ -56,6 +57,7 @@ interface FormProps { enableSaving: boolean; dockLinks: DocLinksStart['links']; toasts: ToastsStart; + trackUiMetric?: (metricType: UiStatsMetricType, eventName: string | string[]) => void; } interface FormState { @@ -149,7 +151,7 @@ export class Form extends PureComponent { if (!setting) { return; } - const { defVal, type, requiresPageReload } = setting; + const { defVal, type, requiresPageReload, metric } = setting; let valueToSave = value; let equalsToDefault = false; switch (type) { @@ -163,6 +165,11 @@ export class Form extends PureComponent { const isArray = Array.isArray(JSON.parse((defVal as string) || '{}')); valueToSave = valueToSave.trim(); valueToSave = valueToSave || (isArray ? '[]' : '{}'); + case 'boolean': + if (metric && this.props.trackUiMetric) { + const metricName = valueToSave ? `${metric.name}_on` : `${metric.name}_off`; + this.props.trackUiMetric(metric.type, metricName); + } default: equalsToDefault = valueToSave === defVal; } diff --git a/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts b/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts index 406bc35f826e8..e5a1ee1437a91 100644 --- a/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts +++ b/src/plugins/advanced_settings/public/management_app/lib/to_editable_config.ts @@ -75,6 +75,7 @@ export function toEditableConfig({ options: def.options, optionLabels: def.optionLabels, requiresPageReload: !!def.requiresPageReload, + metric: def.metric, }; return conf; diff --git a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx index ab348451b1eef..0b3d73cb28806 100644 --- a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx +++ b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx @@ -30,6 +30,7 @@ import { ManagementAppMountParams } from '../../../management/public'; import { ComponentRegistry } from '../types'; import './index.scss'; +import { UsageCollectionSetup } from '../../../usage_collection/public'; const title = i18n.translate('advancedSettings.advancedSettingsLabel', { defaultMessage: 'Advanced Settings', @@ -49,12 +50,14 @@ const readOnlyBadge = { export async function mountManagementSection( getStartServices: StartServicesAccessor, params: ManagementAppMountParams, - componentRegistry: ComponentRegistry['start'] + componentRegistry: ComponentRegistry['start'], + usageCollection?: UsageCollectionSetup ) { params.setBreadcrumbs(crumb); const [{ uiSettings, notifications, docLinks, application, chrome }] = await getStartServices(); const canSave = application.capabilities.advancedSettings.save as boolean; + const trackUiMetric = usageCollection?.reportUiStats.bind(usageCollection, 'advanced_settings'); if (!canSave) { chrome.setBadge(readOnlyBadge); @@ -71,6 +74,7 @@ export async function mountManagementSection( dockLinks={docLinks.links} uiSettings={uiSettings} componentRegistry={componentRegistry} + trackUiMetric={trackUiMetric} /> diff --git a/src/plugins/advanced_settings/public/management_app/types.ts b/src/plugins/advanced_settings/public/management_app/types.ts index 6e243926f7d7d..05e695f998500 100644 --- a/src/plugins/advanced_settings/public/management_app/types.ts +++ b/src/plugins/advanced_settings/public/management_app/types.ts @@ -17,6 +17,7 @@ * under the License. */ +import { UiStatsMetricType } from '@kbn/analytics'; import { UiSettingsType, StringValidation, ImageValidation } from '../../../../core/public'; export interface FieldSetting { @@ -39,6 +40,10 @@ export interface FieldSetting { message: string; docLinksKey: string; }; + metric?: { + type: UiStatsMetricType; + name: string; + }; } // until eui searchbar and query are typed diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 188b11177eaec..165af48b2023c 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -30,7 +30,10 @@ const title = i18n.translate('advancedSettings.advancedSettingsLabel', { export class AdvancedSettingsPlugin implements Plugin { - public setup(core: CoreSetup, { management, home }: AdvancedSettingsPluginSetup) { + public setup( + core: CoreSetup, + { management, home, usageCollection }: AdvancedSettingsPluginSetup + ) { const kibanaSection = management.sections.section.kibana; kibanaSection.registerApp({ @@ -41,7 +44,12 @@ export class AdvancedSettingsPlugin const { mountManagementSection } = await import( './management_app/mount_management_section' ); - return mountManagementSection(core.getStartServices, params, component.start); + return mountManagementSection( + core.getStartServices, + params, + component.start, + usageCollection + ); }, }); diff --git a/src/plugins/advanced_settings/public/types.ts b/src/plugins/advanced_settings/public/types.ts index cc59f52b1f30f..bd5cb0e61fb04 100644 --- a/src/plugins/advanced_settings/public/types.ts +++ b/src/plugins/advanced_settings/public/types.ts @@ -21,6 +21,7 @@ import { ComponentRegistry } from './component_registry'; import { HomePublicPluginSetup } from '../../home/public'; import { ManagementSetup } from '../../management/public'; +import { UsageCollectionSetup } from '../../usage_collection/public'; export interface AdvancedSettingsSetup { component: ComponentRegistry['setup']; @@ -32,6 +33,7 @@ export interface AdvancedSettingsStart { export interface AdvancedSettingsPluginSetup { management: ManagementSetup; home?: HomePublicPluginSetup; + usageCollection?: UsageCollectionSetup; } export { ComponentRegistry }; diff --git a/src/plugins/data/server/server.api.md b/src/plugins/data/server/server.api.md index 2984ca336819a..bb7a8f58c926c 100644 --- a/src/plugins/data/server/server.api.md +++ b/src/plugins/data/server/server.api.md @@ -60,6 +60,7 @@ import { ShardsResponse } from 'elasticsearch'; import { ToastInputFields } from 'src/core/public/notifications'; import { Type } from '@kbn/config-schema'; import { TypeOf } from '@kbn/config-schema'; +import { UiStatsMetricType } from '@kbn/analytics'; import { Unit } from '@elastic/datemath'; // Warning: (ae-forgotten-export) The symbol "AggConfigSerialized" needs to be exported by the entry point index.d.ts diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index 5447b982eef14..f45281ee62202 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -21,6 +21,7 @@ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { UiSettingsParams } from 'kibana/server'; +import { METRIC_TYPE } from '@kbn/analytics'; import { DEFAULT_COLUMNS_SETTING, SAMPLE_SIZE_SETTING, @@ -170,9 +171,13 @@ export const uiSettings: Record = { }), value: true, description: i18n.translate('discover.advancedSettings.discover.modifyColumnsOnSwitchText', { - defaultMessage: 'Remove columns that not available in the new index pattern.', + defaultMessage: 'Remove columns that are not available in the new index pattern.', }), category: ['discover'], schema: schema.boolean(), + metric: { + type: METRIC_TYPE.CLICK, + name: 'discover:modifyColumnsOnSwitchTitle', + }, }, };