From fd1aad0711cf7430a835f583f8a9204016826a25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez=20Haro?= Date: Wed, 16 Sep 2020 20:08:06 +0100 Subject: [PATCH 01/19] [Application Usage] Daily rollups to overcome the find >10k SO limitation (#77610) Co-authored-by: Christiane (Tina) Heiligers --- .../collectors/application_usage/README.md | 10 +- .../application_usage/index.test.ts | 151 --------- .../application_usage/rollups.test.ts | 299 ++++++++++++++++++ .../collectors/application_usage/rollups.ts | 202 ++++++++++++ .../application_usage/saved_objects_types.ts | 32 +- ...emetry_application_usage_collector.test.ts | 213 +++++++++++++ .../telemetry_application_usage_collector.ts | 203 +++++------- .../server/collectors/find_all.test.ts | 55 ---- .../server/collectors/find_all.ts | 41 --- .../telemetry_ui_metric_collector.ts | 4 +- .../kibana_usage_collection/server/plugin.ts | 10 +- .../apis/telemetry/telemetry_local.js | 108 +++++++ 12 files changed, 955 insertions(+), 373 deletions(-) delete mode 100644 src/plugins/kibana_usage_collection/server/collectors/application_usage/index.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts create mode 100644 src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts delete mode 100644 src/plugins/kibana_usage_collection/server/collectors/find_all.test.ts delete mode 100644 src/plugins/kibana_usage_collection/server/collectors/find_all.ts diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md b/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md index 1ffd01fc6fde7..cb80538fd1718 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/README.md @@ -28,10 +28,10 @@ This collection occurs by default for every application registered via the menti ## Developer notes -In order to keep the count of the events, this collector uses 2 Saved Objects: +In order to keep the count of the events, this collector uses 3 Saved Objects: -1. `application_usage_transactional`: It stores each individually reported event (up to 90 days old). Grouped by `timestamp` and `appId`. -2. `application_usage_totals`: It stores the sum of all the events older than 90 days old per `appId`. +1. `application_usage_transactional`: It stores each individually reported event. Grouped by `timestamp` and `appId`. The reason for having these documents instead of editing `application_usage_daily` documents on very report is to provide faster response to the requests to `/api/ui_metric/report` (creating new documents instead of finding and editing existing ones) and to avoid conflicts when multiple users reach to the API concurrently. +2. `application_usage_daily`: Periodically, documents from `application_usage_transactional` are aggregated to daily summaries and deleted. Also grouped by `timestamp` and `appId`. +3. `application_usage_totals`: It stores the sum of all the events older than 90 days old, grouped by `appId`. -Both of them use the shared fields `appId: 'keyword'`, `numberOfClicks: 'long'` and `minutesOnScreen: 'float'`. `application_usage_transactional` also stores `timestamp: { type: 'date' }`. -but they are currently not added in the mappings because we don't use them for search purposes, and we need to be thoughtful with the number of mapped fields in the SavedObjects index ([#43673](https://github.com/elastic/kibana/issues/43673)). +All the types use the shared fields `appId: 'keyword'`, `numberOfClicks: 'long'` and `minutesOnScreen: 'float'`, but they are currently not added in the mappings because we don't use them for search purposes, and we need to be thoughtful with the number of mapped fields in the SavedObjects index ([#43673](https://github.com/elastic/kibana/issues/43673)). `application_usage_transactional` and `application_usage_daily` also store `timestamp: { type: 'date' }`. diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.test.ts deleted file mode 100644 index 5658b38120596..0000000000000 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/index.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { savedObjectsRepositoryMock } from '../../../../../core/server/mocks'; -import { - CollectorOptions, - createUsageCollectionSetupMock, -} from '../../../../usage_collection/server/usage_collection.mock'; - -import { registerApplicationUsageCollector } from './'; -import { - ROLL_INDICES_INTERVAL, - SAVED_OBJECTS_TOTAL_TYPE, - SAVED_OBJECTS_TRANSACTIONAL_TYPE, -} from './telemetry_application_usage_collector'; - -describe('telemetry_application_usage', () => { - jest.useFakeTimers(); - - let collector: CollectorOptions; - - const usageCollectionMock = createUsageCollectionSetupMock(); - usageCollectionMock.makeUsageCollector.mockImplementation((config) => { - collector = config; - return createUsageCollectionSetupMock().makeUsageCollector(config); - }); - - const getUsageCollector = jest.fn(); - const registerType = jest.fn(); - const callCluster = jest.fn(); - - beforeAll(() => - registerApplicationUsageCollector(usageCollectionMock, registerType, getUsageCollector) - ); - afterAll(() => jest.clearAllTimers()); - - test('registered collector is set', () => { - expect(collector).not.toBeUndefined(); - }); - - test('if no savedObjectClient initialised, return undefined', async () => { - expect(await collector.fetch(callCluster)).toBeUndefined(); - jest.runTimersToTime(ROLL_INDICES_INTERVAL); - }); - - test('when savedObjectClient is initialised, return something', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation( - async () => - ({ - saved_objects: [], - total: 0, - } as any) - ); - getUsageCollector.mockImplementation(() => savedObjectClient); - - jest.runTimersToTime(ROLL_INDICES_INTERVAL); // Force rollTotals to run - - expect(await collector.fetch(callCluster)).toStrictEqual({}); - expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled(); - }); - - test('paging in findAll works', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - let total = 201; - savedObjectClient.find.mockImplementation(async (opts) => { - if (opts.type === SAVED_OBJECTS_TOTAL_TYPE) { - return { - saved_objects: [ - { - id: 'appId', - attributes: { - appId: 'appId', - minutesOnScreen: 10, - numberOfClicks: 10, - }, - }, - ], - total: 1, - } as any; - } - if ((opts.page || 1) > 2) { - return { saved_objects: [], total }; - } - const doc = { - id: 'test-id', - attributes: { - appId: 'appId', - timestamp: new Date().toISOString(), - minutesOnScreen: 1, - numberOfClicks: 1, - }, - }; - const savedObjects = new Array(opts.perPage).fill(doc); - total = savedObjects.length * 2 + 1; - return { saved_objects: savedObjects, total }; - }); - - getUsageCollector.mockImplementation(() => savedObjectClient); - - jest.runTimersToTime(ROLL_INDICES_INTERVAL); // Force rollTotals to run - - expect(await collector.fetch(callCluster)).toStrictEqual({ - appId: { - clicks_total: total - 1 + 10, - clicks_7_days: total - 1, - clicks_30_days: total - 1, - clicks_90_days: total - 1, - minutes_on_screen_total: total - 1 + 10, - minutes_on_screen_7_days: total - 1, - minutes_on_screen_30_days: total - 1, - minutes_on_screen_90_days: total - 1, - }, - }); - expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( - [ - { - id: 'appId', - type: SAVED_OBJECTS_TOTAL_TYPE, - attributes: { - appId: 'appId', - minutesOnScreen: total - 1 + 10, - numberOfClicks: total - 1 + 10, - }, - }, - ], - { overwrite: true } - ); - expect(savedObjectClient.delete).toHaveBeenCalledTimes(total - 1); - expect(savedObjectClient.delete).toHaveBeenCalledWith( - SAVED_OBJECTS_TRANSACTIONAL_TYPE, - 'test-id' - ); - }); -}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts new file mode 100644 index 0000000000000..f8bc17fc40df0 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.test.ts @@ -0,0 +1,299 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { rollDailyData, rollTotals } from './rollups'; +import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks'; +import { SavedObjectsErrorHelpers } from '../../../../../core/server'; +import { + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, +} from './saved_objects_types'; + +describe('rollDailyData', () => { + const logger = loggingSystemMock.createLogger(); + + test('returns undefined if no savedObjectsClient initialised yet', async () => { + await expect(rollDailyData(logger, undefined)).resolves.toBe(undefined); + }); + + test('handle empty results', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.get).not.toBeCalled(); + expect(savedObjectClient.bulkCreate).not.toBeCalled(); + expect(savedObjectClient.delete).not.toBeCalled(); + }); + + test('migrate some docs', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + let timesCalled = 0; + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + if (timesCalled++ > 0) { + return { saved_objects: [], total: 0, page, per_page: perPage }; + } + return { + saved_objects: [ + { + id: 'test-id-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + { + id: 'test-id-2', + type, + score: 0, + references: [], + attributes: { + appId: 'appId', + timestamp: '2020-01-01T11:31:00.000Z', + minutesOnScreen: 1.5, + numberOfClicks: 2, + }, + }, + ], + total: 2, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + + savedObjectClient.get.mockImplementation(async (type, id) => { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + }); + + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectClient.get).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId:2020-01-01' + ); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + type: SAVED_OBJECTS_DAILY_TYPE, + id: 'appId:2020-01-01', + attributes: { + appId: 'appId', + timestamp: '2020-01-01T00:00:00.000Z', + minutesOnScreen: 2.0, + numberOfClicks: 3, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(2); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_TRANSACTIONAL_TYPE, + 'test-id-1' + ); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_TRANSACTIONAL_TYPE, + 'test-id-2' + ); + }); + + test('error getting the daily document', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + let timesCalled = 0; + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + if (timesCalled++ > 0) { + return { saved_objects: [], total: 0, page, per_page: perPage }; + } + return { + saved_objects: [ + { + id: 'test-id-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + + savedObjectClient.get.mockImplementation(async (type, id) => { + throw new Error('Something went terribly wrong'); + }); + + await expect(rollDailyData(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.get).toHaveBeenCalledTimes(1); + expect(savedObjectClient.get).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId:2020-01-01' + ); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); + }); +}); + +describe('rollTotals', () => { + const logger = loggingSystemMock.createLogger(); + + test('returns undefined if no savedObjectsClient initialised yet', async () => { + await expect(rollTotals(logger, undefined)).resolves.toBe(undefined); + }); + + test('handle empty results', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_DAILY_TYPE: + case SAVED_OBJECTS_TOTAL_TYPE: + return { saved_objects: [], total: 0, page, per_page: perPage }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(0); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(0); + }); + + test('migrate some documents', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async ({ type, page = 1, perPage = 10 }) => { + switch (type) { + case SAVED_OBJECTS_DAILY_TYPE: + return { + saved_objects: [ + { + id: 'appId-2:2020-01-01', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-2', + timestamp: '2020-01-01T10:31:00.000Z', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + { + id: 'appId-1:2020-01-01', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + timestamp: '2020-01-01T11:31:00.000Z', + minutesOnScreen: 1.5, + numberOfClicks: 2, + }, + }, + ], + total: 2, + page, + per_page: perPage, + }; + case SAVED_OBJECTS_TOTAL_TYPE: + return { + saved_objects: [ + { + id: 'appId-1', + type, + score: 0, + references: [], + attributes: { + appId: 'appId-1', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + page, + per_page: perPage, + }; + default: + throw new Error(`Unexpected type [${type}]`); + } + }); + await expect(rollTotals(logger, savedObjectClient)).resolves.toBe(undefined); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledTimes(1); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-1', + attributes: { + appId: 'appId-1', + minutesOnScreen: 2.0, + numberOfClicks: 3, + }, + }, + { + type: SAVED_OBJECTS_TOTAL_TYPE, + id: 'appId-2', + attributes: { + appId: 'appId-2', + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(2); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-2:2020-01-01' + ); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId-1:2020-01-01' + ); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts new file mode 100644 index 0000000000000..3020147e95d98 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/rollups.ts @@ -0,0 +1,202 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ISavedObjectsRepository, SavedObject, Logger } from 'kibana/server'; +import moment from 'moment'; +import { + ApplicationUsageDaily, + ApplicationUsageTotal, + ApplicationUsageTransactional, + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, +} from './saved_objects_types'; +import { SavedObjectsErrorHelpers } from '../../../../../../src/core/server'; + +/** + * For Rolling the daily data, we only care about the stored attributes and the version (to avoid overwriting via concurrent requests) + */ +type ApplicationUsageDailyWithVersion = Pick< + SavedObject, + 'version' | 'attributes' +>; + +/** + * Aggregates all the transactional events into daily aggregates + * @param logger + * @param savedObjectsClient + */ +export async function rollDailyData(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { + if (!savedObjectsClient) { + return; + } + + try { + let toCreate: Map; + do { + toCreate = new Map(); + const { saved_objects: rawApplicationUsageTransactional } = await savedObjectsClient.find< + ApplicationUsageTransactional + >({ + type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, + perPage: 1000, // Process 1000 at a time as a compromise of speed and overload + }); + + for (const doc of rawApplicationUsageTransactional) { + const { + attributes: { appId, minutesOnScreen, numberOfClicks, timestamp }, + } = doc; + const dayId = moment(timestamp).format('YYYY-MM-DD'); + const dailyId = `${appId}:${dayId}`; + const existingDoc = + toCreate.get(dailyId) || (await getDailyDoc(savedObjectsClient, dailyId, appId, dayId)); + toCreate.set(dailyId, { + ...existingDoc, + attributes: { + ...existingDoc.attributes, + minutesOnScreen: existingDoc.attributes.minutesOnScreen + minutesOnScreen, + numberOfClicks: existingDoc.attributes.numberOfClicks + numberOfClicks, + }, + }); + } + if (toCreate.size > 0) { + await savedObjectsClient.bulkCreate( + [...toCreate.entries()].map(([id, { attributes, version }]) => ({ + type: SAVED_OBJECTS_DAILY_TYPE, + id, + attributes, + version, // Providing version to ensure via conflict matching that only 1 Kibana instance (or interval) is taking care of the updates + })), + { overwrite: true } + ); + await Promise.all( + rawApplicationUsageTransactional.map( + ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_TRANSACTIONAL_TYPE, id) // There is no bulkDelete :( + ) + ); + } + } while (toCreate.size > 0); + } catch (err) { + logger.warn(`Failed to rollup transactional to daily entries`); + logger.warn(err); + } +} + +/** + * Gets daily doc from the SavedObjects repository. Creates a new one if not found + * @param savedObjectsClient + * @param id The ID of the document to retrieve (typically, `${appId}:${dayId}`) + * @param appId The application ID + * @param dayId The date of the document in the format YYYY-MM-DD + */ +async function getDailyDoc( + savedObjectsClient: ISavedObjectsRepository, + id: string, + appId: string, + dayId: string +): Promise { + try { + return await savedObjectsClient.get(SAVED_OBJECTS_DAILY_TYPE, id); + } catch (err) { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + return { + attributes: { + appId, + // Concatenating the day in YYYY-MM-DD form to T00:00:00Z to reduce the TZ effects + timestamp: moment(`${moment(dayId).format('YYYY-MM-DD')}T00:00:00Z`).toISOString(), + minutesOnScreen: 0, + numberOfClicks: 0, + }, + }; + } + throw err; + } +} + +/** + * Moves all the daily documents into aggregated "total" documents as we don't care about any granularity after 90 days + * @param logger + * @param savedObjectsClient + */ +export async function rollTotals(logger: Logger, savedObjectsClient?: ISavedObjectsRepository) { + if (!savedObjectsClient) { + return; + } + + try { + const [ + { saved_objects: rawApplicationUsageTotals }, + { saved_objects: rawApplicationUsageDaily }, + ] = await Promise.all([ + savedObjectsClient.find({ + perPage: 10000, + type: SAVED_OBJECTS_TOTAL_TYPE, + }), + savedObjectsClient.find({ + perPage: 10000, + type: SAVED_OBJECTS_DAILY_TYPE, + filter: `${SAVED_OBJECTS_DAILY_TYPE}.attributes.timestamp < now-90d`, + }), + ]); + + const existingTotals = rawApplicationUsageTotals.reduce( + (acc, { attributes: { appId, numberOfClicks, minutesOnScreen } }) => { + return { + ...acc, + // No need to sum because there should be 1 document per appId only + [appId]: { appId, numberOfClicks, minutesOnScreen }, + }; + }, + {} as Record + ); + + const totals = rawApplicationUsageDaily.reduce((acc, { attributes }) => { + const { appId, numberOfClicks, minutesOnScreen } = attributes; + + const existing = acc[appId] || { minutesOnScreen: 0, numberOfClicks: 0 }; + + return { + ...acc, + [appId]: { + appId, + numberOfClicks: numberOfClicks + existing.numberOfClicks, + minutesOnScreen: minutesOnScreen + existing.minutesOnScreen, + }, + }; + }, existingTotals); + + await Promise.all([ + Object.entries(totals).length && + savedObjectsClient.bulkCreate( + Object.entries(totals).map(([id, entry]) => ({ + type: SAVED_OBJECTS_TOTAL_TYPE, + id, + attributes: entry, + })), + { overwrite: true } + ), + ...rawApplicationUsageDaily.map( + ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_DAILY_TYPE, id) // There is no bulkDelete :( + ), + ]); + } catch (err) { + logger.warn(`Failed to rollup daily entries to totals`); + logger.warn(err); + } +} diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts index 551c6e230972e..861dc98c0c465 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/saved_objects_types.ts @@ -19,19 +19,34 @@ import { SavedObjectAttributes, SavedObjectsServiceSetup } from 'kibana/server'; +/** + * Used for accumulating the totals of all the stats older than 90d + */ export interface ApplicationUsageTotal extends SavedObjectAttributes { appId: string; minutesOnScreen: number; numberOfClicks: number; } +export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; +/** + * Used for storing each of the reports received from the users' browsers + */ export interface ApplicationUsageTransactional extends ApplicationUsageTotal { timestamp: string; } +export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional'; + +/** + * Used to aggregate the transactional events into daily summaries so we can purge the granular events + */ +export type ApplicationUsageDaily = ApplicationUsageTransactional; +export const SAVED_OBJECTS_DAILY_TYPE = 'application_usage_daily'; export function registerMappings(registerType: SavedObjectsServiceSetup['registerType']) { + // Type for storing ApplicationUsageTotal registerType({ - name: 'application_usage_totals', + name: SAVED_OBJECTS_TOTAL_TYPE, hidden: false, namespaceType: 'agnostic', mappings: { @@ -42,15 +57,28 @@ export function registerMappings(registerType: SavedObjectsServiceSetup['registe }, }); + // Type for storing ApplicationUsageDaily registerType({ - name: 'application_usage_transactional', + name: SAVED_OBJECTS_DAILY_TYPE, hidden: false, namespaceType: 'agnostic', mappings: { dynamic: false, properties: { + // This type requires `timestamp` to be indexed so we can use it when rolling up totals (timestamp < now-90d) timestamp: { type: 'date' }, }, }, }); + + // Type for storing ApplicationUsageTransactional (declaring empty mappings because we don't use the internal fields for query/aggregations) + registerType({ + name: SAVED_OBJECTS_TRANSACTIONAL_TYPE, + hidden: false, + namespaceType: 'agnostic', + mappings: { + dynamic: false, + properties: {}, + }, + }); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts new file mode 100644 index 0000000000000..709736a37d802 --- /dev/null +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.test.ts @@ -0,0 +1,213 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { savedObjectsRepositoryMock, loggingSystemMock } from '../../../../../core/server/mocks'; +import { + CollectorOptions, + createUsageCollectionSetupMock, +} from '../../../../usage_collection/server/usage_collection.mock'; + +import { + ROLL_INDICES_START, + ROLL_TOTAL_INDICES_INTERVAL, + registerApplicationUsageCollector, +} from './telemetry_application_usage_collector'; +import { + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, +} from './saved_objects_types'; + +describe('telemetry_application_usage', () => { + jest.useFakeTimers(); + + const logger = loggingSystemMock.createLogger(); + + let collector: CollectorOptions; + + const usageCollectionMock = createUsageCollectionSetupMock(); + usageCollectionMock.makeUsageCollector.mockImplementation((config) => { + collector = config; + return createUsageCollectionSetupMock().makeUsageCollector(config); + }); + + const getUsageCollector = jest.fn(); + const registerType = jest.fn(); + const callCluster = jest.fn(); + + beforeAll(() => + registerApplicationUsageCollector(logger, usageCollectionMock, registerType, getUsageCollector) + ); + afterAll(() => jest.clearAllTimers()); + + test('registered collector is set', () => { + expect(collector).not.toBeUndefined(); + }); + + test('if no savedObjectClient initialised, return undefined', async () => { + expect(collector.isReady()).toBe(false); + expect(await collector.fetch(callCluster)).toBeUndefined(); + jest.runTimersToTime(ROLL_INDICES_START); + }); + + test('when savedObjectClient is initialised, return something', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation( + async () => + ({ + saved_objects: [], + total: 0, + } as any) + ); + getUsageCollector.mockImplementation(() => savedObjectClient); + + jest.runTimersToTime(ROLL_TOTAL_INDICES_INTERVAL); // Force rollTotals to run + + expect(collector.isReady()).toBe(true); + expect(await collector.fetch(callCluster)).toStrictEqual({}); + expect(savedObjectClient.bulkCreate).not.toHaveBeenCalled(); + }); + + test('it only gets 10k even when there are more documents (ES limitation)', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + const total = 10000; + savedObjectClient.find.mockImplementation(async (opts) => { + switch (opts.type) { + case SAVED_OBJECTS_TOTAL_TYPE: + return { + saved_objects: [ + { + id: 'appId', + attributes: { + appId: 'appId', + minutesOnScreen: 10, + numberOfClicks: 10, + }, + }, + ], + total: 1, + } as any; + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + const doc = { + id: 'test-id', + attributes: { + appId: 'appId', + timestamp: new Date().toISOString(), + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }; + const savedObjects = new Array(total).fill(doc); + return { saved_objects: savedObjects, total: total + 1 }; + case SAVED_OBJECTS_DAILY_TYPE: + return { + saved_objects: [ + { + id: 'appId:YYYY-MM-DD', + attributes: { + appId: 'appId', + timestamp: new Date().toISOString(), + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + }; + } + }); + + getUsageCollector.mockImplementation(() => savedObjectClient); + + jest.runTimersToTime(ROLL_TOTAL_INDICES_INTERVAL); // Force rollTotals to run + + expect(await collector.fetch(callCluster)).toStrictEqual({ + appId: { + clicks_total: total + 1 + 10, + clicks_7_days: total + 1, + clicks_30_days: total + 1, + clicks_90_days: total + 1, + minutes_on_screen_total: (total + 1) * 0.5 + 10, + minutes_on_screen_7_days: (total + 1) * 0.5, + minutes_on_screen_30_days: (total + 1) * 0.5, + minutes_on_screen_90_days: (total + 1) * 0.5, + }, + }); + expect(savedObjectClient.bulkCreate).toHaveBeenCalledWith( + [ + { + id: 'appId', + type: SAVED_OBJECTS_TOTAL_TYPE, + attributes: { + appId: 'appId', + minutesOnScreen: 10.5, + numberOfClicks: 11, + }, + }, + ], + { overwrite: true } + ); + expect(savedObjectClient.delete).toHaveBeenCalledTimes(1); + expect(savedObjectClient.delete).toHaveBeenCalledWith( + SAVED_OBJECTS_DAILY_TYPE, + 'appId:YYYY-MM-DD' + ); + }); + + test('old transactional data not migrated yet', async () => { + const savedObjectClient = savedObjectsRepositoryMock.create(); + savedObjectClient.find.mockImplementation(async (opts) => { + switch (opts.type) { + case SAVED_OBJECTS_TOTAL_TYPE: + case SAVED_OBJECTS_DAILY_TYPE: + return { saved_objects: [], total: 0 } as any; + case SAVED_OBJECTS_TRANSACTIONAL_TYPE: + return { + saved_objects: [ + { + id: 'test-id', + attributes: { + appId: 'appId', + timestamp: new Date(0).toISOString(), + minutesOnScreen: 0.5, + numberOfClicks: 1, + }, + }, + ], + total: 1, + }; + } + }); + + getUsageCollector.mockImplementation(() => savedObjectClient); + + expect(await collector.fetch(callCluster)).toStrictEqual({ + appId: { + clicks_total: 1, + clicks_7_days: 0, + clicks_30_days: 0, + clicks_90_days: 0, + minutes_on_screen_total: 0.5, + minutes_on_screen_7_days: 0, + minutes_on_screen_30_days: 0, + minutes_on_screen_90_days: 0, + }, + }); + }); +}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts index 69137681e0597..36c89d0a0b4a8 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/application_usage/telemetry_application_usage_collector.ts @@ -18,29 +18,42 @@ */ import moment from 'moment'; -import { ISavedObjectsRepository, SavedObjectsServiceSetup } from 'kibana/server'; +import { timer } from 'rxjs'; +import { ISavedObjectsRepository, Logger, SavedObjectsServiceSetup } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { findAll } from '../find_all'; import { + ApplicationUsageDaily, ApplicationUsageTotal, ApplicationUsageTransactional, registerMappings, + SAVED_OBJECTS_DAILY_TYPE, + SAVED_OBJECTS_TOTAL_TYPE, + SAVED_OBJECTS_TRANSACTIONAL_TYPE, } from './saved_objects_types'; import { applicationUsageSchema } from './schema'; +import { rollDailyData, rollTotals } from './rollups'; /** - * Roll indices every 24h + * Roll total indices every 24h */ -export const ROLL_INDICES_INTERVAL = 24 * 60 * 60 * 1000; +export const ROLL_TOTAL_INDICES_INTERVAL = 24 * 60 * 60 * 1000; + +/** + * Roll daily indices every 30 minutes. + * This means that, assuming a user can visit all the 44 apps we can possibly report + * in the 3 minutes interval the browser reports to the server, up to 22 users can have the same + * behaviour and we wouldn't need to paginate in the transactional documents (less than 10k docs). + * + * Based on a more normal expected use case, the users could visit up to 5 apps in those 3 minutes, + * allowing up to 200 users before reaching the limit. + */ +export const ROLL_DAILY_INDICES_INTERVAL = 30 * 60 * 1000; /** * Start rolling indices after 5 minutes up */ export const ROLL_INDICES_START = 5 * 60 * 1000; -export const SAVED_OBJECTS_TOTAL_TYPE = 'application_usage_totals'; -export const SAVED_OBJECTS_TRANSACTIONAL_TYPE = 'application_usage_transactional'; - export interface ApplicationUsageTelemetryReport { [appId: string]: { clicks_total: number; @@ -55,6 +68,7 @@ export interface ApplicationUsageTelemetryReport { } export function registerApplicationUsageCollector( + logger: Logger, usageCollection: UsageCollectionSetup, registerType: SavedObjectsServiceSetup['registerType'], getSavedObjectsClient: () => ISavedObjectsRepository | undefined @@ -71,10 +85,22 @@ export function registerApplicationUsageCollector( if (typeof savedObjectsClient === 'undefined') { return; } - const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([ - findAll(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }), - findAll(savedObjectsClient, { + const [ + { saved_objects: rawApplicationUsageTotals }, + { saved_objects: rawApplicationUsageDaily }, + { saved_objects: rawApplicationUsageTransactional }, + ] = await Promise.all([ + savedObjectsClient.find({ + type: SAVED_OBJECTS_TOTAL_TYPE, + perPage: 10000, // We only have 44 apps for now. This limit is OK. + }), + savedObjectsClient.find({ + type: SAVED_OBJECTS_DAILY_TYPE, + perPage: 10000, // We can have up to 44 apps * 91 days = 4004 docs. This limit is OK + }), + savedObjectsClient.find({ type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, + perPage: 10000, // If we have more than those, we won't report the rest (they'll be rolled up to the daily soon enough to become a problem) }), ]); @@ -101,51 +127,51 @@ export function registerApplicationUsageCollector( const nowMinus30 = moment().subtract(30, 'days'); const nowMinus90 = moment().subtract(90, 'days'); - const applicationUsage = rawApplicationUsageTransactional.reduce( - (acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => { - const existing = acc[appId] || { - clicks_total: 0, - clicks_7_days: 0, - clicks_30_days: 0, - clicks_90_days: 0, - minutes_on_screen_total: 0, - minutes_on_screen_7_days: 0, - minutes_on_screen_30_days: 0, - minutes_on_screen_90_days: 0, - }; - - const timeOfEntry = moment(timestamp as string); - const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7); - const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30); - const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90); - - const last7Days = { - clicks_7_days: existing.clicks_7_days + numberOfClicks, - minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen, - }; - const last30Days = { - clicks_30_days: existing.clicks_30_days + numberOfClicks, - minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen, - }; - const last90Days = { - clicks_90_days: existing.clicks_90_days + numberOfClicks, - minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen, - }; - - return { - ...acc, - [appId]: { - ...existing, - clicks_total: existing.clicks_total + numberOfClicks, - minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen, - ...(isInLast7Days ? last7Days : {}), - ...(isInLast30Days ? last30Days : {}), - ...(isInLast90Days ? last90Days : {}), - }, - }; - }, - applicationUsageFromTotals - ); + const applicationUsage = [ + ...rawApplicationUsageDaily, + ...rawApplicationUsageTransactional, + ].reduce((acc, { attributes: { appId, minutesOnScreen, numberOfClicks, timestamp } }) => { + const existing = acc[appId] || { + clicks_total: 0, + clicks_7_days: 0, + clicks_30_days: 0, + clicks_90_days: 0, + minutes_on_screen_total: 0, + minutes_on_screen_7_days: 0, + minutes_on_screen_30_days: 0, + minutes_on_screen_90_days: 0, + }; + + const timeOfEntry = moment(timestamp); + const isInLast7Days = timeOfEntry.isSameOrAfter(nowMinus7); + const isInLast30Days = timeOfEntry.isSameOrAfter(nowMinus30); + const isInLast90Days = timeOfEntry.isSameOrAfter(nowMinus90); + + const last7Days = { + clicks_7_days: existing.clicks_7_days + numberOfClicks, + minutes_on_screen_7_days: existing.minutes_on_screen_7_days + minutesOnScreen, + }; + const last30Days = { + clicks_30_days: existing.clicks_30_days + numberOfClicks, + minutes_on_screen_30_days: existing.minutes_on_screen_30_days + minutesOnScreen, + }; + const last90Days = { + clicks_90_days: existing.clicks_90_days + numberOfClicks, + minutes_on_screen_90_days: existing.minutes_on_screen_90_days + minutesOnScreen, + }; + + return { + ...acc, + [appId]: { + ...existing, + clicks_total: existing.clicks_total + numberOfClicks, + minutes_on_screen_total: existing.minutes_on_screen_total + minutesOnScreen, + ...(isInLast7Days ? last7Days : {}), + ...(isInLast30Days ? last30Days : {}), + ...(isInLast90Days ? last90Days : {}), + }, + }; + }, applicationUsageFromTotals); return applicationUsage; }, @@ -154,65 +180,10 @@ export function registerApplicationUsageCollector( usageCollection.registerCollector(collector); - setInterval(() => rollTotals(getSavedObjectsClient()), ROLL_INDICES_INTERVAL); - setTimeout(() => rollTotals(getSavedObjectsClient()), ROLL_INDICES_START); -} - -async function rollTotals(savedObjectsClient?: ISavedObjectsRepository) { - if (!savedObjectsClient) { - return; - } - - try { - const [rawApplicationUsageTotals, rawApplicationUsageTransactional] = await Promise.all([ - findAll(savedObjectsClient, { type: SAVED_OBJECTS_TOTAL_TYPE }), - findAll(savedObjectsClient, { - type: SAVED_OBJECTS_TRANSACTIONAL_TYPE, - filter: `${SAVED_OBJECTS_TRANSACTIONAL_TYPE}.attributes.timestamp < now-90d`, - }), - ]); - - const existingTotals = rawApplicationUsageTotals.reduce( - (acc, { attributes: { appId, numberOfClicks, minutesOnScreen } }) => { - return { - ...acc, - // No need to sum because there should be 1 document per appId only - [appId]: { appId, numberOfClicks, minutesOnScreen }, - }; - }, - {} as Record - ); - - const totals = rawApplicationUsageTransactional.reduce((acc, { attributes, id }) => { - const { appId, numberOfClicks, minutesOnScreen } = attributes; - - const existing = acc[appId] || { minutesOnScreen: 0, numberOfClicks: 0 }; - - return { - ...acc, - [appId]: { - appId, - numberOfClicks: numberOfClicks + existing.numberOfClicks, - minutesOnScreen: minutesOnScreen + existing.minutesOnScreen, - }, - }; - }, existingTotals); - - await Promise.all([ - Object.entries(totals).length && - savedObjectsClient.bulkCreate( - Object.entries(totals).map(([id, entry]) => ({ - type: SAVED_OBJECTS_TOTAL_TYPE, - id, - attributes: entry, - })), - { overwrite: true } - ), - ...rawApplicationUsageTransactional.map( - ({ id }) => savedObjectsClient.delete(SAVED_OBJECTS_TRANSACTIONAL_TYPE, id) // There is no bulkDelete :( - ), - ]); - } catch (err) { - // Silent failure - } + timer(ROLL_INDICES_START, ROLL_DAILY_INDICES_INTERVAL).subscribe(() => + rollDailyData(logger, getSavedObjectsClient()) + ); + timer(ROLL_INDICES_START, ROLL_TOTAL_INDICES_INTERVAL).subscribe(() => + rollTotals(logger, getSavedObjectsClient()) + ); } diff --git a/src/plugins/kibana_usage_collection/server/collectors/find_all.test.ts b/src/plugins/kibana_usage_collection/server/collectors/find_all.test.ts deleted file mode 100644 index d917cd2454e81..0000000000000 --- a/src/plugins/kibana_usage_collection/server/collectors/find_all.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { savedObjectsRepositoryMock } from '../../../../core/server/mocks'; - -import { findAll } from './find_all'; - -describe('telemetry_application_usage', () => { - test('when savedObjectClient is initialised, return something', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - savedObjectClient.find.mockImplementation( - async () => - ({ - saved_objects: [], - total: 0, - } as any) - ); - - expect(await findAll(savedObjectClient, { type: 'test-type' })).toStrictEqual([]); - }); - - test('paging in findAll works', async () => { - const savedObjectClient = savedObjectsRepositoryMock.create(); - let total = 201; - const doc = { id: 'test-id', attributes: { test: 1 } }; - savedObjectClient.find.mockImplementation(async (opts) => { - if ((opts.page || 1) > 2) { - return { saved_objects: [], total } as any; - } - const savedObjects = new Array(opts.perPage).fill(doc); - total = savedObjects.length * 2 + 1; - return { saved_objects: savedObjects, total }; - }); - - expect(await findAll(savedObjectClient, { type: 'test-type' })).toStrictEqual( - new Array(total - 1).fill(doc) - ); - }); -}); diff --git a/src/plugins/kibana_usage_collection/server/collectors/find_all.ts b/src/plugins/kibana_usage_collection/server/collectors/find_all.ts deleted file mode 100644 index 5bb4f20b5c5b1..0000000000000 --- a/src/plugins/kibana_usage_collection/server/collectors/find_all.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { - SavedObjectAttributes, - ISavedObjectsRepository, - SavedObjectsFindOptions, - SavedObject, -} from 'kibana/server'; - -export async function findAll( - savedObjectsClient: ISavedObjectsRepository, - opts: SavedObjectsFindOptions -): Promise>> { - const { page = 1, perPage = 10000, ...options } = opts; - const { saved_objects: savedObjects, total } = await savedObjectsClient.find({ - ...options, - page, - perPage, - }); - if (page * perPage >= total) { - return savedObjects; - } - return [...savedObjects, ...(await findAll(savedObjectsClient, { ...opts, page: page + 1 }))]; -} diff --git a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts index 46768813b1970..9c02a9cbf3204 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/ui_metric/telemetry_ui_metric_collector.ts @@ -23,7 +23,6 @@ import { SavedObjectsServiceSetup, } from 'kibana/server'; import { UsageCollectionSetup } from 'src/plugins/usage_collection/server'; -import { findAll } from '../find_all'; interface UIMetricsSavedObjects extends SavedObjectAttributes { count: number; @@ -55,9 +54,10 @@ export function registerUiMetricUsageCollector( return; } - const rawUiMetrics = await findAll(savedObjectsClient, { + const { saved_objects: rawUiMetrics } = await savedObjectsClient.find({ type: 'ui-metric', fields: ['count'], + perPage: 10000, }); const uiMetricsByAppName = rawUiMetrics.reduce((accum, rawUiMetric) => { diff --git a/src/plugins/kibana_usage_collection/server/plugin.ts b/src/plugins/kibana_usage_collection/server/plugin.ts index d4295c770803e..260acd19ab516 100644 --- a/src/plugins/kibana_usage_collection/server/plugin.ts +++ b/src/plugins/kibana_usage_collection/server/plugin.ts @@ -30,6 +30,7 @@ import { CoreStart, SavedObjectsServiceSetup, OpsMetrics, + Logger, } from '../../../core/server'; import { registerApplicationUsageCollector, @@ -47,12 +48,14 @@ interface KibanaUsageCollectionPluginsDepsSetup { type SavedObjectsRegisterType = SavedObjectsServiceSetup['registerType']; export class KibanaUsageCollectionPlugin implements Plugin { + private readonly logger: Logger; private readonly legacyConfig$: Observable; private savedObjectsClient?: ISavedObjectsRepository; private uiSettingsClient?: IUiSettingsClient; private metric$: Subject; constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); this.legacyConfig$ = initializerContext.config.legacy.globalConfig$; this.metric$ = new Subject(); } @@ -88,7 +91,12 @@ export class KibanaUsageCollectionPlugin implements Plugin { registerKibanaUsageCollector(usageCollection, this.legacyConfig$); registerManagementUsageCollector(usageCollection, getUiSettingsClient); registerUiMetricUsageCollector(usageCollection, registerType, getSavedObjectsClient); - registerApplicationUsageCollector(usageCollection, registerType, getSavedObjectsClient); + registerApplicationUsageCollector( + this.logger.get('application-usage'), + usageCollection, + registerType, + getSavedObjectsClient + ); registerCspCollector(usageCollection, coreSetup.http); } } diff --git a/test/api_integration/apis/telemetry/telemetry_local.js b/test/api_integration/apis/telemetry/telemetry_local.js index d2d61705b763d..9a5467e622ff3 100644 --- a/test/api_integration/apis/telemetry/telemetry_local.js +++ b/test/api_integration/apis/telemetry/telemetry_local.js @@ -154,5 +154,113 @@ export default function ({ getService }) { expect(expected.every((m) => actual.includes(m))).to.be.ok(); }); + + describe('application usage limits', () => { + const timeRange = { + min: '2018-07-23T22:07:00Z', + max: '2018-07-23T22:13:00Z', + }; + + function createSavedObject() { + return supertest + .post('/api/saved_objects/application_usage_transactional') + .send({ + attributes: { + appId: 'test-app', + minutesOnScreen: 10.99, + numberOfClicks: 10, + timestamp: new Date().toISOString(), + }, + }) + .expect(200) + .then((resp) => resp.body.id); + } + + describe('basic behaviour', () => { + let savedObjectId; + before('create 1 entry', async () => { + return createSavedObject().then((id) => (savedObjectId = id)); + }); + after('cleanup', () => { + return supertest + .delete(`/api/saved_objects/application_usage_transactional/${savedObjectId}`) + .expect(200); + }); + + it('should return application_usage data', async () => { + const { body } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, unencrypted: true }) + .expect(200); + + expect(body.length).to.be(1); + const stats = body[0]; + expect(stats.stack_stats.kibana.plugins.application_usage).to.eql({ + 'test-app': { + clicks_total: 10, + clicks_7_days: 10, + clicks_30_days: 10, + clicks_90_days: 10, + minutes_on_screen_total: 10.99, + minutes_on_screen_7_days: 10.99, + minutes_on_screen_30_days: 10.99, + minutes_on_screen_90_days: 10.99, + }, + }); + }); + }); + + describe('10k + 1', () => { + const savedObjectIds = []; + before('create 10k + 1 entries for application usage', async () => { + await supertest + .post('/api/saved_objects/_bulk_create') + .send( + new Array(10001).fill(0).map(() => ({ + type: 'application_usage_transactional', + attributes: { + appId: 'test-app', + minutesOnScreen: 1, + numberOfClicks: 1, + timestamp: new Date().toISOString(), + }, + })) + ) + .expect(200) + .then((resp) => resp.body.saved_objects.forEach(({ id }) => savedObjectIds.push(id))); + }); + after('clean them all', async () => { + // The SavedObjects API does not allow bulk deleting, and deleting one by one takes ages and the tests timeout + await es.deleteByQuery({ + index: '.kibana', + body: { query: { term: { type: 'application_usage_transactional' } } }, + }); + }); + + it("should only use the first 10k docs for the application_usage data (they'll be rolled up in a later process)", async () => { + const { body } = await supertest + .post('/api/telemetry/v2/clusters/_stats') + .set('kbn-xsrf', 'xxx') + .send({ timeRange, unencrypted: true }) + .expect(200); + + expect(body.length).to.be(1); + const stats = body[0]; + expect(stats.stack_stats.kibana.plugins.application_usage).to.eql({ + 'test-app': { + clicks_total: 10000, + clicks_7_days: 10000, + clicks_30_days: 10000, + clicks_90_days: 10000, + minutes_on_screen_total: 10000, + minutes_on_screen_7_days: 10000, + minutes_on_screen_30_days: 10000, + minutes_on_screen_90_days: 10000, + }, + }); + }); + }); + }); }); } From 3688df286a146d6fc82ffac70417b09db05030e9 Mon Sep 17 00:00:00 2001 From: Shahzad Date: Wed, 16 Sep 2020 21:20:45 +0200 Subject: [PATCH 02/19] [RUM Dashboard] Handle invalid service name param (#77163) Co-authored-by: Elastic Machine --- .../apm/e2e/cypress/integration/helpers.ts | 9 ++++++-- .../step_definitions/csm/csm_dashboard.ts | 12 ++++++---- x-pack/plugins/apm/e2e/yarn.lock | 8 +++---- .../app/RumDashboard/ClientMetrics/index.tsx | 3 ++- .../ServiceNameFilter/index.tsx | 22 ++++++++++++++----- 5 files changed, 38 insertions(+), 16 deletions(-) diff --git a/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts b/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts index 1956f1c2d9f0d..462304a959102 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts +++ b/x-pack/plugins/apm/e2e/cypress/integration/helpers.ts @@ -11,14 +11,19 @@ export const DEFAULT_TIMEOUT = 60 * 1000; export function loginAndWaitForPage( url: string, - dateRange: { to: string; from: string } + dateRange: { to: string; from: string }, + selectedService?: string ) { const username = Cypress.env('elasticsearch_username'); const password = Cypress.env('elasticsearch_password'); cy.log(`Authenticating via ${username} / ${password}`); - const fullUrl = `${BASE_URL}${url}?rangeFrom=${dateRange.from}&rangeTo=${dateRange.to}`; + let fullUrl = `${BASE_URL}${url}?rangeFrom=${dateRange.from}&rangeTo=${dateRange.to}`; + + if (selectedService) { + fullUrl += `&serviceName=${selectedService}`; + } cy.visit(fullUrl, { auth: { username, password } }); cy.viewport('macbook-15'); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts index a57241a197ca4..461e2960c5e02 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts @@ -15,10 +15,14 @@ Given(`a user browses the APM UI application for RUM Data`, () => { // open service overview page const RANGE_FROM = 'now-24h'; const RANGE_TO = 'now'; - loginAndWaitForPage(`/app/csm`, { - from: RANGE_FROM, - to: RANGE_TO, - }); + loginAndWaitForPage( + `/app/csm`, + { + from: RANGE_FROM, + to: RANGE_TO, + }, + 'client' + ); }); Then(`should have correct client metrics`, () => { diff --git a/x-pack/plugins/apm/e2e/yarn.lock b/x-pack/plugins/apm/e2e/yarn.lock index 936294052aa7b..fc63189e97ea3 100644 --- a/x-pack/plugins/apm/e2e/yarn.lock +++ b/x-pack/plugins/apm/e2e/yarn.lock @@ -5494,10 +5494,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= -typescript@3.9.6: - version "3.9.6" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.6.tgz#8f3e0198a34c3ae17091b35571d3afd31999365a" - integrity sha512-Pspx3oKAPJtjNwE92YS05HQoY7z2SFyOpHo9MqJor3BXAGNaPUs83CuVp9VISFkSjyRfiTpmKuAYGJB7S7hOxw== +typescript@4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.0.2.tgz#7ea7c88777c723c681e33bf7988be5d008d05ac2" + integrity sha512-e4ERvRV2wb+rRZ/IQeb3jm2VxBsirQLpQhdxplZ2MEzGvDkkMmPglecnNDfSUBivMjP93vRbngYYDQqQ/78bcQ== umd@^3.0.0: version "3.0.3" diff --git a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx index f54a54211359c..1edfd724dadd7 100644 --- a/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx +++ b/x-pack/plugins/apm/public/components/app/RumDashboard/ClientMetrics/index.tsx @@ -26,7 +26,8 @@ export function ClientMetrics() { const { data, status } = useFetcher( (callApmApi) => { - if (start && end) { + const { serviceName } = uiFilters; + if (start && end && serviceName) { return callApmApi({ pathname: '/api/apm/rum/client-metrics', params: { diff --git a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx index 3deba69a25df2..cbf9ba009dce2 100644 --- a/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/LocalUIFilters/ServiceNameFilter/index.tsx @@ -24,7 +24,7 @@ interface Props { function ServiceNameFilter({ loading, serviceNames }: Props) { const history = useHistory(); const { - urlParams: { serviceName }, + urlParams: { serviceName: selectedServiceName }, } = useUrlParams(); const options = serviceNames.map((type) => ({ @@ -47,10 +47,22 @@ function ServiceNameFilter({ loading, serviceNames }: Props) { ); useEffect(() => { - if (!serviceName && serviceNames.length > 0) { - updateServiceName(serviceNames[0]); + if (serviceNames?.length > 0) { + // select first from the list + if (!selectedServiceName) { + updateServiceName(serviceNames[0]); + } + + // in case serviceName is cached from url and isn't present in current list + if (selectedServiceName && !serviceNames.includes(selectedServiceName)) { + updateServiceName(serviceNames[0]); + } + } + + if (selectedServiceName && serviceNames.length === 0 && !loading) { + updateServiceName(''); } - }, [serviceNames, serviceName, updateServiceName]); + }, [serviceNames, selectedServiceName, updateServiceName, loading]); return ( <> @@ -68,7 +80,7 @@ function ServiceNameFilter({ loading, serviceNames }: Props) { isLoading={loading} data-cy="serviceNameFilter" options={options} - value={serviceName} + value={selectedServiceName} compressed={true} onChange={(event) => { updateServiceName(event.target.value); From 17fec25e3c281b4f66e5973efbaef0368b9152c5 Mon Sep 17 00:00:00 2001 From: Jonathan Buttner <56361221+jonathan-buttner@users.noreply.github.com> Date: Wed, 16 Sep 2020 15:28:30 -0400 Subject: [PATCH 03/19] [Security Solution] Refactor resolver children _source (#77343) * Moving generator to safe type version * Finished generator and alert * Gzipping again * Finishing type conversions for backend * Trying to cast front end tests back to unsafe type for now * Working reducer tests * Adding more comments and fixing alert type * Restoring resolver test data * Updating snapshot with timestamp info * Getting the models figured out * Event models type fixes * Adding more comments * Fixing more comments * Adding comments --- .../common/endpoint/models/event.ts | 176 ++++++++++++++++-- .../routes/resolver/queries/children.ts | 68 ++++++- .../routes/resolver/utils/children_helper.ts | 10 +- .../resolver/utils/children_pagination.ts | 4 +- .../utils/children_start_query_handler.ts | 5 +- 5 files changed, 229 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/security_solution/common/endpoint/models/event.ts b/x-pack/plugins/security_solution/common/endpoint/models/event.ts index 07208214a641a..9634659b1a5dd 100644 --- a/x-pack/plugins/security_solution/common/endpoint/models/event.ts +++ b/x-pack/plugins/security_solution/common/endpoint/models/event.ts @@ -3,20 +3,24 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { - LegacyEndpointEvent, - ResolverEvent, - SafeResolverEvent, - SafeLegacyEndpointEvent, -} from '../types'; +import { LegacyEndpointEvent, ResolverEvent, SafeResolverEvent, ECSField } from '../types'; import { firstNonNullValue, hasValue, values } from './ecs_safety_helpers'; +/** + * Legacy events will define the `endgame` object. This is used to narrow a ResolverEvent. + */ +interface LegacyEvent { + endgame?: object; +} + /* - * Determine if a `ResolverEvent` is the legacy variety. Can be used to narrow `ResolverEvent` to `LegacyEndpointEvent`. + * Determine if a higher level event type is the legacy variety. Can be used to narrow an event type. + * T optionally defines an `endgame` object field used for determining the type of event. If T doesn't contain the + * `endgame` field it will serve as the narrowed type. */ -export function isLegacyEventSafeVersion( - event: SafeResolverEvent -): event is SafeLegacyEndpointEvent { +export function isLegacyEventSafeVersion( + event: LegacyEvent | {} +): event is T { return 'endgame' in event && event.endgame !== undefined; } @@ -27,7 +31,30 @@ export function isLegacyEvent(event: ResolverEvent): event is LegacyEndpointEven return (event as LegacyEndpointEvent).endgame !== undefined; } -export function isProcessRunning(event: SafeResolverEvent): boolean { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type ProcessRunningFields = Partial< + | { + endgame: object; + event: Partial<{ + type: ECSField; + action: ECSField; + }>; + } + | { + event: Partial<{ + type: ECSField; + }>; + } +>; + +/** + * Checks if an event describes a process as running (whether it was started, already running, or changed) + * + * @param event a document to check for running fields + */ +export function isProcessRunning(event: ProcessRunningFields): boolean { if (isLegacyEventSafeVersion(event)) { return ( hasValue(event.event?.type, 'process_start') || @@ -43,7 +70,18 @@ export function isProcessRunning(event: SafeResolverEvent): boolean { ); } -export function timestampSafeVersion(event: SafeResolverEvent): undefined | number { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type TimestampFields = Pick; + +/** + * Extracts the first non null value from the `@timestamp` field in the document. Returns undefined if the field doesn't + * exist in the document. + * + * @param event a document from ES + */ +export function timestampSafeVersion(event: TimestampFields): undefined | number { return firstNonNullValue(event?.['@timestamp']); } @@ -51,7 +89,7 @@ export function timestampSafeVersion(event: SafeResolverEvent): undefined | numb * The `@timestamp` for the event, as a `Date` object. * If `@timestamp` couldn't be parsed as a `Date`, returns `undefined`. */ -export function timestampAsDateSafeVersion(event: SafeResolverEvent): Date | undefined { +export function timestampAsDateSafeVersion(event: TimestampFields): Date | undefined { const value = timestampSafeVersion(event); if (value === undefined) { return undefined; @@ -93,9 +131,30 @@ export function eventId(event: ResolverEvent): number | undefined | string { return event.event.id; } -export function eventSequence(event: SafeResolverEvent): number | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type EventSequenceFields = Partial< + | { + endgame: Partial<{ + serial_event_id: ECSField; + }>; + } + | { + event: Partial<{ + sequence: ECSField; + }>; + } +>; + +/** + * Extract the first non null event sequence value from a document. Returns undefined if the field doesn't exist in the document. + * + * @param event a document from ES + */ +export function eventSequence(event: EventSequenceFields): number | undefined { if (isLegacyEventSafeVersion(event)) { - return firstNonNullValue(event.endgame.serial_event_id); + return firstNonNullValue(event.endgame?.serial_event_id); } return firstNonNullValue(event.event?.sequence); } @@ -113,7 +172,29 @@ export function entityId(event: ResolverEvent): string { return event.process.entity_id; } -export function entityIDSafeVersion(event: SafeResolverEvent): string | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type EntityIDFields = Partial< + | { + endgame: Partial<{ + unique_pid: ECSField; + }>; + } + | { + process: Partial<{ + entity_id: ECSField; + }>; + } +>; + +/** + * Extract the first non null value from either the `entity_id` or `unique_pid` depending on the document type. Returns + * undefined if the field doesn't exist in the document. + * + * @param event a document from ES + */ +export function entityIDSafeVersion(event: EntityIDFields): string | undefined { if (isLegacyEventSafeVersion(event)) { return event.endgame?.unique_pid === undefined ? undefined @@ -130,14 +211,59 @@ export function parentEntityId(event: ResolverEvent): string | undefined { return event.process.parent?.entity_id; } -export function parentEntityIDSafeVersion(event: SafeResolverEvent): string | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type ParentEntityIDFields = Partial< + | { + endgame: Partial<{ + unique_ppid: ECSField; + }>; + } + | { + process: Partial<{ + parent: Partial<{ + entity_id: ECSField; + }>; + }>; + } +>; + +/** + * Extract the first non null value from either the `parent.entity_id` or `unique_ppid` depending on the document type. Returns + * undefined if the field doesn't exist in the document. + * + * @param event a document from ES + */ +export function parentEntityIDSafeVersion(event: ParentEntityIDFields): string | undefined { if (isLegacyEventSafeVersion(event)) { - return String(firstNonNullValue(event.endgame.unique_ppid)); + return String(firstNonNullValue(event.endgame?.unique_ppid)); } return firstNonNullValue(event.process?.parent?.entity_id); } -export function ancestryArray(event: SafeResolverEvent): string[] | undefined { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type AncestryArrayFields = Partial< + | { + endgame: object; + } + | { + process: Partial<{ + Ext: Partial<{ + ancestry: ECSField; + }>; + }>; + } +>; + +/** + * Extracts all ancestry array from a document if it exists. + * + * @param event an ES document + */ +export function ancestryArray(event: AncestryArrayFields): string[] | undefined { if (isLegacyEventSafeVersion(event)) { return undefined; } @@ -146,7 +272,17 @@ export function ancestryArray(event: SafeResolverEvent): string[] | undefined { return values(event.process?.Ext?.ancestry); } -export function getAncestryAsArray(event: SafeResolverEvent | undefined): string[] { +/** + * Minimum fields needed from the `SafeResolverEvent` type for the function below to operate correctly. + */ +type GetAncestryArrayFields = AncestryArrayFields & ParentEntityIDFields; + +/** + * Returns an array of strings representing the ancestry for a process. + * + * @param event an ES document + */ +export function getAncestryAsArray(event: GetAncestryArrayFields | undefined): string[] { if (!event) { return []; } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts index 8c7daf9451217..4c7be9b8d5a24 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/queries/children.ts @@ -4,15 +4,59 @@ * you may not use this file except in compliance with the Elastic License. */ import { SearchResponse } from 'elasticsearch'; -import { SafeResolverEvent } from '../../../../../common/endpoint/types'; +import { ECSField } from '../../../../../common/endpoint/types'; import { ResolverQuery } from './base'; import { ChildrenPaginationBuilder } from '../utils/children_pagination'; import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/common'; +/** + * This type represents the document returned from ES for a legacy event when using the ChildrenQuery to fetch legacy events. + * It contains only the necessary fields that the children api needs to process the results before + * it requests the full lifecycle information for the children in a later query. + */ +export type LegacyChildEvent = Partial<{ + '@timestamp': ECSField; + event: Partial<{ + type: ECSField; + action: ECSField; + }>; + endgame: Partial<{ + serial_event_id: ECSField; + unique_pid: ECSField; + unique_ppid: ECSField; + }>; +}>; + +/** + * This type represents the document returned from ES for an event when using the ChildrenQuery to fetch legacy events. + * It contains only the necessary fields that the children api needs to process the results before + * it requests the full lifecycle information for the children in a later query. + */ +export type EndpointChildEvent = Partial<{ + '@timestamp': ECSField; + event: Partial<{ + type: ECSField; + sequence: ECSField; + }>; + process: Partial<{ + entity_id: ECSField; + parent: Partial<{ + entity_id: ECSField; + }>; + Ext: Partial<{ + ancestry: ECSField; + }>; + }>; +}>; + +export type ChildEvent = EndpointChildEvent | LegacyChildEvent; + /** * Builds a query for retrieving descendants of a node. + * The first type `ChildEvent[]` represents the final formatted result. The second type `ChildEvent` defines the type + * used in the `SearchResponse` field returned from the ES query. */ -export class ChildrenQuery extends ResolverQuery { +export class ChildrenQuery extends ResolverQuery { constructor( private readonly pagination: ChildrenPaginationBuilder, indexPattern: string | string[], @@ -24,6 +68,14 @@ export class ChildrenQuery extends ResolverQuery { protected legacyQuery(endpointID: string, uniquePIDs: string[]): JsonObject { const paginationFields = this.pagination.buildQueryFields('endgame.serial_event_id'); return { + _source: [ + '@timestamp', + 'endgame.serial_event_id', + 'endgame.unique_pid', + 'endgame.unique_ppid', + 'event.type', + 'event.action', + ], collapse: { field: 'endgame.unique_pid', }, @@ -64,8 +116,18 @@ export class ChildrenQuery extends ResolverQuery { } protected query(entityIDs: string[]): JsonObject { + // we don't have to include the `event.id` in the source response because it is not needed for processing + // the data returned by ES, it is only used for breaking ties when ES is doing the search const paginationFields = this.pagination.buildQueryFields('event.id'); return { + _source: [ + '@timestamp', + 'event.type', + 'event.sequence', + 'process.entity_id', + 'process.parent.entity_id', + 'process.Ext.ancestry', + ], /** * Using collapse here will only return a single event per occurrence of a process.entity_id. The events are sorted * based on timestamp in ascending order so it will be the first event that ocurred. The actual type of event that @@ -126,7 +188,7 @@ export class ChildrenQuery extends ResolverQuery { }; } - formatResponse(response: SearchResponse): SafeResolverEvent[] { + formatResponse(response: SearchResponse): ChildEvent[] { return this.getResults(response); } } diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts index e9174548898dd..f54472141c1de 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_helper.ts @@ -17,6 +17,7 @@ import { } from '../../../../../common/endpoint/types'; import { createChild } from './node'; import { ChildrenPaginationBuilder } from './children_pagination'; +import { ChildEvent } from '../queries/children'; /** * This class helps construct the children structure when building a resolver tree. @@ -86,15 +87,12 @@ export class ChildrenNodesHelper { * @param queriedNodes the entity_ids of the nodes that returned these start events * @param startEvents an array of start events returned by ES */ - addStartEvents( - queriedNodes: Set, - startEvents: SafeResolverEvent[] - ): Set | undefined { + addStartEvents(queriedNodes: Set, startEvents: ChildEvent[]): Set | undefined { let largestAncestryArray = 0; const nodesToQueryNext: Map> = new Map(); const nonLeafNodes: Set = new Set(); - const isDistantGrandchild = (event: SafeResolverEvent) => { + const isDistantGrandchild = (event: ChildEvent) => { const ancestry = getAncestryAsArray(event); return ancestry.length > 0 && queriedNodes.has(ancestry[ancestry.length - 1]); }; @@ -161,7 +159,7 @@ export class ChildrenNodesHelper { return nodesToQueryNext.get(largestAncestryArray); } - private setPaginationForNodes(nodes: Set, startEvents: SafeResolverEvent[]) { + private setPaginationForNodes(nodes: Set, startEvents: ChildEvent[]) { for (const nodeEntityID of nodes.values()) { const cachedNode = this.entityToNodeCache.get(nodeEntityID); if (cachedNode) { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_pagination.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_pagination.ts index 4cc8aaf42d12b..e202124554873 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_pagination.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_pagination.ts @@ -4,10 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SafeResolverEvent } from '../../../../../common/endpoint/types'; import { eventSequence, timestampSafeVersion } from '../../../../../common/endpoint/models/event'; import { JsonObject } from '../../../../../../../../src/plugins/kibana_utils/common'; import { urlEncodeCursor, SortFields, urlDecodeCursor } from './pagination'; +import { ChildEvent } from '../queries/children'; /** * Pagination information for the children class. @@ -65,7 +65,7 @@ export class ChildrenPaginationBuilder { * * @param results the events that were returned by the ES query */ - static buildCursor(results: SafeResolverEvent[]): string | null { + static buildCursor(results: ChildEvent[]): string | null { const lastResult = results[results.length - 1]; const sequence = eventSequence(lastResult); const cursor = { diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_start_query_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_start_query_handler.ts index 327be8d3696fd..9f5f085d5b80d 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_start_query_handler.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/resolver/utils/children_start_query_handler.ts @@ -6,8 +6,7 @@ import { SearchResponse } from 'elasticsearch'; import { ILegacyScopedClusterClient } from 'kibana/server'; -import { SafeResolverEvent } from '../../../../../common/endpoint/types'; -import { ChildrenQuery } from '../queries/children'; +import { ChildEvent, ChildrenQuery } from '../queries/children'; import { QueryInfo } from '../queries/multi_searcher'; import { QueryHandler } from './fetch'; import { ChildrenNodesHelper } from './children_helper'; @@ -46,7 +45,7 @@ export class ChildrenStartQueryHandler implements QueryHandler) => { + private handleResponse = (response: SearchResponse) => { const results = this.query.formatResponse(response); this.nodesToQuery = this.childrenHelper.addStartEvents(this.nodesToQuery, results) ?? new Set(); From 37527f94f343fdd48c0a4843f9a95325924a7040 Mon Sep 17 00:00:00 2001 From: Charlie Pichette <56399229+charlie-pichette@users.noreply.github.com> Date: Wed, 16 Sep 2020 14:10:59 -0600 Subject: [PATCH 04/19] Validate href for reassign policy link (#77534) --- .../apps/endpoint/endpoint_list.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts index 7485e5db20478..99689ca03603d 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/endpoint_list.ts @@ -161,11 +161,18 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(endpointDetailTitleNew).to.equal(endpointDetailTitleInitial); }); - // The integration does not work properly yet. Skipping this test for now. - it.skip('navigates to ingest fleet when the Reassign Policy link is clicked', async () => { + // Just check the href link is correct - would need to load ingest data to validate the integration + it('navigates to ingest fleet when the Reassign Policy link is clicked', async () => { + // The prior test results in a tooltip. We need to move the mouse to clear it and allow the click + await (await testSubjects.find('hostnameCellLink')).moveMouseTo(); await (await testSubjects.find('hostnameCellLink')).click(); - await (await testSubjects.find('endpointDetailsLinkToIngest')).click(); - await testSubjects.existOrFail('fleetAgentListTable'); + const endpointDetailsLinkToIngestButton = await testSubjects.find( + 'endpointDetailsLinkToIngest' + ); + const hrefLink = await endpointDetailsLinkToIngestButton.getAttribute('href'); + expect(hrefLink).to.contain( + '/app/ingestManager#/fleet/agents/023fa40c-411d-4188-a941-4147bfadd095/activity?openReassignFlyout=true' + ); }); }); From 4f0edbd160711711fe832bd488282eab6b8ee4cd Mon Sep 17 00:00:00 2001 From: IgorG <56408662+IgorGuz2000@users.noreply.github.com> Date: Wed, 16 Sep 2020 16:16:20 -0400 Subject: [PATCH 05/19] Functional Test for Resolver fix (#77116) * Final I hope check in for Resolver fix * Fix click * Fix click * Fix click * revert to select the first event * Gzip Data file * removed not zipped file * striped Data file and gziped * removed commented out delete indices * Added query bar to select correct events * removed commented out delete indices * removed commented out delete indices * removed commented out delete indices * removed commented out delete indices Co-authored-by: Elastic Machine --- .../endpoint/resolver_tree/data.json.gz | Bin 0 -> 3488 bytes .../apps/endpoint/index.ts | 1 + .../apps/endpoint/resolver.ts | 197 ++++++++++++++++++ .../test/security_solution_endpoint/config.ts | 4 + .../page_objects/hosts_page.ts | 109 ++++++++++ .../page_objects/index.ts | 2 + .../apis/data_stream_helper.ts | 5 + 7 files changed, 318 insertions(+) create mode 100644 x-pack/test/functional/es_archives/endpoint/resolver_tree/data.json.gz create mode 100644 x-pack/test/security_solution_endpoint/apps/endpoint/resolver.ts create mode 100644 x-pack/test/security_solution_endpoint/page_objects/hosts_page.ts diff --git a/x-pack/test/functional/es_archives/endpoint/resolver_tree/data.json.gz b/x-pack/test/functional/es_archives/endpoint/resolver_tree/data.json.gz new file mode 100644 index 0000000000000000000000000000000000000000..0c3e40ce6eb5434de0c3a91114d86724267f2998 GIT binary patch literal 3488 zcmXYzc{~&TAIF`eWrYwUN93%D&DnBQu3SkZiiMFgM>L{C=FBadYwjyojv=9P6z0m& z9J92nTyxBsV?Xu#{`-EsKcCO*{d&J1kLO1$mXkC2y@LZ2t1}`10de>D@n%{k+4)Zz z3NU@&-Ur5%ka|CD+$C9=j%lh=)ZdCbJ%kU`&bb?15Ef44i0%xOXN1~KpGTNHIqvb@ zL>j7^e8=7B*M>nOgiPj(>Derjvj%pFug5avi6`3@2E-43?q%xb5ozpH`RwUvGfMJ2 zd-{!*^wfTvk?TMFh%}6)qu%A;QXLV?EV`OOPt~UV$t^3qRhl_gD+{%v39eBEx@26c zgKS28d+gB(`KI8ZuYV)1^-pe>p*b?%ML#9Y9jndzTzTLp|b zEqOi?O+s5syKUF6KD|i|qSdDVyYB}7vYKLI$W7C>td`Poc&l&13ES4)UC(yZle=Vn z8(RO2Y3S$nay7-rl^+vB!h)AoPqsJPwKKHJ$4|v-zxSI8ifOM}v7aW+8FVE887rlS zlt=viX=@zOTbdnL8t*=6h+j&IY3r)Arf8a(q$gV|94_Fly}f+f23ecb)i+D)wCa6u zqqQ*G9v2r#r+8R2*o9>@nQl+EY7|vf>b9cbr7I$q+uiMcLHOcJrNj}^EEUL+b36SrUu|WdxYgeOd-%F^=E#5SpW?;AuFjM&)EUw@2r;Xc19v4Z@BoP zFzPPOjPFwk^89d365~?Y)jM;zTlH@aOiX#o#oP5#@4B#0i4B=VzgxjIoTA8tAJ&X2 z$eFQa3J7#z`TP)nE{_=f`UD*Qq`mquq^Q!zR$^n&0cm|c#?s3Uo+;a4e>lE6+g{oBskv_+T8J^f}4j<@sV0r1tkB+&s|4*~p% z%#}rskbcUgwK@m#%%mc$oCCLwf7AQ}odml;vy^cisc5#DDic%j9ETlLdy!$h*%<3L z8T#MD0w&#;i96m6UsaEsb^t-9p0(Feu{{&FrkWDGl8t|r$5UDoz0DAUKT}|;e_Y;0 zw2`7lWTyi7OM1R4#21>pPO*m>xP_x!@?&$Jr6}OGZ$5qB26w$F5NS1{YVlYkCRvsp(*prumn4+`^um+|3XQ2^TgDDbEYE3-|A)8lc1NO2IHPqQ99 z$OOGe*r?0F?h3)kP>OgP7*nPK?&~0Yt}?I1OjM4s9^Ffg#(bpkIQ+;B`0!WxKb;BK zu_df+qOL!NCn|sJgUJD9ZT+G@2^1gz4ZpL);Rv`$ze-44i&B();n#$eZWKJKuwtI) zV7oBQis4kHE2A+RgeUj!qcCO502y0?$XL}N^Iw5Z$Wf!-bCPT{WbcLldy?x)b;V$D zx^&EJY85B4YIObLm1vE@`^8q|D%PU`%sUA`yY(V9Inmhv8?ixv1EAY3LvWnt8C(V)eW3dbXFl{ z#1jh70LIDRz)_H=dneU0pv773dk6b6mq^5k`}Pm6ic~i90z4xB7(2dlwH%NdrQX`M z+@`lYekr1VyDe_`vq;K=imRk58oNw(pwI1{`cBu3-^}Hi6>>h-+CYNQwAS*xFCCV& zC{;3b#s@koe%UC-=qYzelGbKPw)*OT*GQo9gq`=)ih(bmbc7M>>Vxl(CfBg0)s%>^ zxKNGDT1Ekg;0a_WI!a@?WUs(UvIu$sE}9cAbO^q5w?slaL&+ms4f}{BdNQRfc+>}n z4|(UGT%4F5A=h&k+`G&MM=z>F49v(E#ua9$$epi zai`(epf57yCKc_ZInoxq-c*$?n%NM7*od;Lo2f18U7`$oq}js%81rbyA%Swvi3oxt zRh&QA5cM)x^_XU77^Fr~>64`pub_}7=J?ebSM80m$jj!_MOZ#P?mP?esBrFMBEn9o zhN7A|dELqVYo3|>!9i}+-L{12M_I=rNTPNz_)Gje;9ynjw4jsy)bZ{3{@40%T4qAt zmHyiE&w;RG11k>X?`pljzRtg}?h@=GHske2(>C*XT1nGu9;4ObX*g%q1(R~FD)w(x zX&$bpY!9EajpSt5Fxxd-5yu@AJ%7b1%#h~<9V1j4UxW0naY^4XHjDa{rvg!9wjubx zl(zKmKd<)IIqqRkfS_Y|Eh^y0oya|V1(~00k?}fc-u`-Cs|O#;l%Dh9*VeB|r^nxY zqpo{Lj-vkjgE5<|jPr;g*BM4_*A8OCM_+i6sgZi~wH7hQQ=X@%*RD(ImwX%Rt)>FeHpXk% zSRPRs-y3-HU|F5qH=7ro$5Z6PJ9U2yw#d(_yx8~F>U*9Arz1)fxz;;QbD+ps_z~%# zsxR498a*-Z-;ra(n!n8EcQ?E*_1(d<5fnJn()jQPlia!{EL@z-FNr#OCgzMwHj?-| z9Al(befsyNF~3GUd1b6zb$N{8ChB#Dh$|+GL5+MvhRj1Gv=Xe<_ks5grac>q$>*2h zWPZvdKwV)Mqk&frv#{*6MoZv)(M#_k@2n)8|9 zP(kwnH%pRKTH}WWh$p5FR~3GTMxmC9;@X$n4$k%3zmEB1;IS^y=Fs=LIh;~=rMpj9 zsI8H_BH^7^ulG<0dRDdg7wa^OH*+JLc%fs~Kq0j>eWSpvLyB{D-c&$UiH6yTtW=NR z->OkLV_r*_ddHyK$sV?IVQ~y3*{{SpMq+Psfi9GMCNNC*X(#@F#aCz^Qj!;2?^jilFkR%B4r?y+Pz{gU?ZhAJ;Zi@EZT#M|z=p!N z`>u673JNTA@=+0UXLoxn1^!B{h~rP=9b$50f-!-|Pk{Vy+J_m2Yj3}DuVw=64fJW} zI7)!M1DQd>vFOyWykMXkksB6Oo2yr9+8loBpJ-13Fs--=4#w!c zNkBXH*c^4DHDL9m_AO?RM*`puVqo=A6ARrGwGRJ>Fd`pxI%O6KkjWZbaV4sWrC&#H z9r3wG=ZMc=YG#}TNgqrUh;pZBvUx`g{7WxHD4|BYYYsR3Y#8b=yaOEi9!9(e0rO!| z)A*pb$K7uGz?^f|7cR<#UQs`C{jZZ1Ah1AT6PCl4J|>p6@6D0G$qS9zr*!ZyToWz8NXw{yDhWZKq~Y%z4<+-j!ph zhS|;W>~c$ip)p|azZEQ^(Ytbx1c1bsg#v`4Jp_!8Zco3~k(V&Q49a|l-jJr&rn1~@ zD*jK?88EfDNeEW)aYWqX=|0{b_V})UVry~UZc@;{0L_vN@$5RKCbEdtng0T0_BTL& zZrpyG1~N0W_xs23r!zj1*`b~_LLx2&fE>WFBM={Lc!aA?4&n-ktT-GIC(~f*9_VcY zt)3HROXCa77({D-ZU(1Kl;Miw>v&1FBi#S}wV*eZI*y{g;;Qp_ldO4zEOA}`ike}q z8?kDW!3L|i3^t6Yh2__PWP$0rEMO+kGxVljN9f;iGb5};LAbVkrEoNZ8CzI7`IMEJ F>3`fB$teH; literal 0 HcmV?d00001 diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts index ad1980cd7218b..c25233a0c82eb 100644 --- a/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/index.ts @@ -30,5 +30,6 @@ export default function (providerContext: FtrProviderContext) { }); loadTestFile(require.resolve('./endpoint_list')); loadTestFile(require.resolve('./policy_details')); + loadTestFile(require.resolve('./resolver')); }); } diff --git a/x-pack/test/security_solution_endpoint/apps/endpoint/resolver.ts b/x-pack/test/security_solution_endpoint/apps/endpoint/resolver.ts new file mode 100644 index 0000000000000..725edb6d98198 --- /dev/null +++ b/x-pack/test/security_solution_endpoint/apps/endpoint/resolver.ts @@ -0,0 +1,197 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const pageObjects = getPageObjects(['common', 'timePicker', 'hosts', 'settings']); + const testSubjects = getService('testSubjects'); + const esArchiver = getService('esArchiver'); + const queryBar = getService('queryBar'); + + describe('Endpoint Event Resolver', function () { + before(async () => { + await esArchiver.load('endpoint/resolver_tree', { useCreate: true }); + await pageObjects.hosts.navigateToSecurityHostsPage(); + const fromTime = 'Jan 1, 2018 @ 00:00:00.000'; + const toTime = 'now'; + await pageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await queryBar.setQuery('event.dataset : endpoint.events.file'); + await queryBar.submitQuery(); + await (await testSubjects.find('draggable-content-host.name')).click(); + await testSubjects.existOrFail('header-page-title'); + await (await testSubjects.find('navigation-events')).click(); + await testSubjects.existOrFail('events-viewer-panel'); + await testSubjects.exists('investigate-in-resolver-button', { timeout: 4000 }); + await (await testSubjects.findAll('investigate-in-resolver-button'))[0].click(); + }); + + after(async () => { + await pageObjects.hosts.deleteDataStreams(); + }); + + it('check that Resolver and Data table is loaded', async () => { + await testSubjects.existOrFail('resolver:graph'); + await testSubjects.existOrFail('tableHeaderCell_name_0'); + await testSubjects.existOrFail('tableHeaderCell_timestamp_1'); + }); + + it('compare resolver Nodes Table data and Data length', async () => { + const nodeData: string[] = []; + const TableData: string[] = []; + + const Table = await testSubjects.findAll('resolver:node-list:item'); + for (const value of Table) { + const text = await value._webElement.getText(); + TableData.push(text.split('\n')[0]); + } + await (await testSubjects.find('resolver:graph-controls:zoom-out')).click(); + const Nodes = await testSubjects.findAll('resolver:node:primary-button'); + for (const value of Nodes) { + nodeData.push(await value._webElement.getText()); + } + for (let i = 0; i < nodeData.length; i++) { + expect(TableData[i]).to.eql(nodeData[i]); + } + expect(nodeData.length).to.eql(TableData.length); + await (await testSubjects.find('resolver:graph-controls:zoom-in')).click(); + }); + + it('resolver Nodes navigation Up', async () => { + const OriginalNodeDataStyle = await pageObjects.hosts.parseStyles(); + await (await testSubjects.find('resolver:graph-controls:north-button')).click(); + + const NewNodeDataStyle = await pageObjects.hosts.parseStyles(); + for (let i = 0; i < OriginalNodeDataStyle.length; i++) { + expect(parseFloat(OriginalNodeDataStyle[i].top)).to.lessThan( + parseFloat(NewNodeDataStyle[i].top) + ); + expect(parseFloat(OriginalNodeDataStyle[i].left)).to.equal( + parseFloat(NewNodeDataStyle[i].left) + ); + } + await (await testSubjects.find('resolver:graph-controls:center-button')).click(); + }); + + it('resolver Nodes navigation Down', async () => { + const OriginalNodeDataStyle = await pageObjects.hosts.parseStyles(); + await (await testSubjects.find('resolver:graph-controls:south-button')).click(); + + const NewNodeDataStyle = await pageObjects.hosts.parseStyles(); + for (let i = 0; i < NewNodeDataStyle.length; i++) { + expect(parseFloat(NewNodeDataStyle[i].top)).to.lessThan( + parseFloat(OriginalNodeDataStyle[i].top) + ); + expect(parseFloat(OriginalNodeDataStyle[i].left)).to.equal( + parseFloat(NewNodeDataStyle[i].left) + ); + } + await (await testSubjects.find('resolver:graph-controls:center-button')).click(); + }); + + it('resolver Nodes navigation Left', async () => { + const OriginalNodeDataStyle = await pageObjects.hosts.parseStyles(); + await (await testSubjects.find('resolver:graph-controls:east-button')).click(); + + const NewNodeDataStyle = await pageObjects.hosts.parseStyles(); + for (let i = 0; i < OriginalNodeDataStyle.length; i++) { + expect(parseFloat(NewNodeDataStyle[i].left)).to.lessThan( + parseFloat(OriginalNodeDataStyle[i].left) + ); + expect(parseFloat(NewNodeDataStyle[i].top)).to.equal( + parseFloat(OriginalNodeDataStyle[i].top) + ); + } + await (await testSubjects.find('resolver:graph-controls:center-button')).click(); + }); + + it('resolver Nodes navigation Right', async () => { + const OriginalNodeDataStyle = await pageObjects.hosts.parseStyles(); + await testSubjects.click('resolver:graph-controls:west-button'); + const NewNodeDataStyle = await pageObjects.hosts.parseStyles(); + for (let i = 0; i < NewNodeDataStyle.length; i++) { + expect(parseFloat(OriginalNodeDataStyle[i].left)).to.lessThan( + parseFloat(NewNodeDataStyle[i].left) + ); + expect(parseFloat(NewNodeDataStyle[i].top)).to.equal( + parseFloat(OriginalNodeDataStyle[i].top) + ); + } + await (await testSubjects.find('resolver:graph-controls:center-button')).click(); + }); + + it('resolver Nodes navigation Center', async () => { + const OriginalNodeDataStyle = await pageObjects.hosts.parseStyles(); + await (await testSubjects.find('resolver:graph-controls:east-button')).click(); + await (await testSubjects.find('resolver:graph-controls:south-button')).click(); + + const NewNodeDataStyle = await pageObjects.hosts.parseStyles(); + for (let i = 0; i < NewNodeDataStyle.length; i++) { + expect(parseFloat(NewNodeDataStyle[i].left)).to.lessThan( + parseFloat(OriginalNodeDataStyle[i].left) + ); + expect(parseFloat(NewNodeDataStyle[i].top)).to.lessThan( + parseFloat(OriginalNodeDataStyle[i].top) + ); + } + await (await testSubjects.find('resolver:graph-controls:center-button')).click(); + const CenterNodeDataStyle = await pageObjects.hosts.parseStyles(); + + for (let i = 0; i < CenterNodeDataStyle.length; i++) { + expect(parseFloat(CenterNodeDataStyle[i].left)).to.equal( + parseFloat(OriginalNodeDataStyle[i].left) + ); + expect(parseFloat(CenterNodeDataStyle[i].top)).to.equal( + parseFloat(OriginalNodeDataStyle[i].top) + ); + } + }); + + it('resolver Nodes navigation zoom in', async () => { + const OriginalNodeDataStyle = await pageObjects.hosts.parseStyles(); + await (await testSubjects.find('resolver:graph-controls:zoom-in')).click(); + + const NewNodeDataStyle = await pageObjects.hosts.parseStyles(); + for (let i = 1; i < NewNodeDataStyle.length; i++) { + expect(parseFloat(NewNodeDataStyle[i].left)).to.lessThan( + parseFloat(OriginalNodeDataStyle[i].left) + ); + expect(parseFloat(NewNodeDataStyle[i].top)).to.lessThan( + parseFloat(OriginalNodeDataStyle[i].top) + ); + expect(parseFloat(OriginalNodeDataStyle[i].width)).to.lessThan( + parseFloat(NewNodeDataStyle[i].width) + ); + expect(parseFloat(OriginalNodeDataStyle[i].height)).to.lessThan( + parseFloat(NewNodeDataStyle[i].height) + ); + await (await testSubjects.find('resolver:graph-controls:zoom-out')).click(); + } + }); + + it('resolver Nodes navigation zoom out', async () => { + const OriginalNodeDataStyle = await pageObjects.hosts.parseStyles(); + await (await testSubjects.find('resolver:graph-controls:zoom-out')).click(); + const NewNodeDataStyle1 = await pageObjects.hosts.parseStyles(); + for (let i = 1; i < OriginalNodeDataStyle.length; i++) { + expect(parseFloat(OriginalNodeDataStyle[i].left)).to.lessThan( + parseFloat(NewNodeDataStyle1[i].left) + ); + expect(parseFloat(OriginalNodeDataStyle[i].top)).to.lessThan( + parseFloat(NewNodeDataStyle1[i].top) + ); + expect(parseFloat(NewNodeDataStyle1[i].width)).to.lessThan( + parseFloat(OriginalNodeDataStyle[i].width) + ); + expect(parseFloat(NewNodeDataStyle1[i].height)).to.lessThan( + parseFloat(OriginalNodeDataStyle[i].height) + ); + } + await (await testSubjects.find('resolver:graph-controls:zoom-in')).click(); + }); + }); +} diff --git a/x-pack/test/security_solution_endpoint/config.ts b/x-pack/test/security_solution_endpoint/config.ts index 5aa5e42ffd4ee..840862ab00560 100644 --- a/x-pack/test/security_solution_endpoint/config.ts +++ b/x-pack/test/security_solution_endpoint/config.ts @@ -30,6 +30,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { ['securitySolutionManagement']: { pathname: '/app/security/administration', }, + ...xpackFunctionalConfig.get('apps'), + ['security']: { + pathname: '/app/security', + }, }, kbnTestServer: { ...xpackFunctionalConfig.get('kbnTestServer'), diff --git a/x-pack/test/security_solution_endpoint/page_objects/hosts_page.ts b/x-pack/test/security_solution_endpoint/page_objects/hosts_page.ts new file mode 100644 index 0000000000000..c5f7d5b5fdf31 --- /dev/null +++ b/x-pack/test/security_solution_endpoint/page_objects/hosts_page.ts @@ -0,0 +1,109 @@ +/* + * 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 { FtrProviderContext } from '../ftr_provider_context'; +import { deleteEventsStream } from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; +import { deleteAlertsStream } from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; +import { deleteMetadataStream } from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; +import { deletePolicyStream } from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; +import { deleteTelemetryStream } from '../../security_solution_endpoint_api_int/apis/data_stream_helper'; +export interface DataStyle { + left: string; + top: string; + width: string; + height: string; +} + +export function SecurityHostsPageProvider({ getService, getPageObjects }: FtrProviderContext) { + const pageObjects = getPageObjects(['common', 'header']); + const testSubjects = getService('testSubjects'); + + /** + * @function parseStyles + * Parses a string of inline styles into a typescript object with casing for react + * @param {string} styles + * @returns {Object} + */ + const parseStyle = ( + styles: string + ): { + left?: string; + top?: string; + width?: string; + height?: string; + } => + styles + .split(';') + .filter((style: string) => style.split(':')[0] && style.split(':')[1]) + .map((style: string) => [ + style + .split(':')[0] + .trim() + .replace(/-./g, (c: string) => c.substr(1).toUpperCase()), + style.split(':').slice(1).join(':').trim(), + ]) + .reduce( + (styleObj: {}, style: string[]) => ({ + ...styleObj, + [style[0]]: style[1], + }), + {} + ); + return { + /** + * Navigate to the Security Hosts page + */ + async navigateToSecurityHostsPage() { + await pageObjects.common.navigateToUrlWithBrowserHistory('security', '/hosts/AllHosts'); + await pageObjects.header.waitUntilLoadingHasFinished(); + }, + /** + * Finds a table and returns the data in a nested array with row 0 is the headers if they exist. + * It uses euiTableCellContent to avoid poluting the array data with the euiTableRowCell__mobileHeader data. + * @param dataTestSubj + * @param element + * @returns Promise + */ + async getEndpointEventResolverNodeData(dataTestSubj: string, element: string) { + await testSubjects.exists(dataTestSubj); + const Elements = await testSubjects.findAll(dataTestSubj); + const $ = []; + for (const value of Elements) { + $.push(await value.getAttribute(element)); + } + return $; + }, + + /** + * Gets a array of not parsed styles and returns the Array of parsed styles. + * @returns Promise + */ + async parseStyles() { + const tableData = await this.getEndpointEventResolverNodeData('resolver:node', 'style'); + const styles: DataStyle[] = []; + for (let i = 1; i < tableData.length; i++) { + const eachStyle = parseStyle(tableData[i]); + styles.push({ + top: eachStyle.top ?? '', + height: eachStyle.height ?? '', + left: eachStyle.left ?? '', + width: eachStyle.width ?? '', + }); + } + return styles; + }, + /** + * Deletes DataStreams from Index Management. + */ + async deleteDataStreams() { + await deleteEventsStream(getService); + await deleteAlertsStream(getService); + await deletePolicyStream(getService); + await deleteMetadataStream(getService); + await deleteTelemetryStream(getService); + }, + }; +} diff --git a/x-pack/test/security_solution_endpoint/page_objects/index.ts b/x-pack/test/security_solution_endpoint/page_objects/index.ts index 39a9e7009c385..3664a2033d8b7 100644 --- a/x-pack/test/security_solution_endpoint/page_objects/index.ts +++ b/x-pack/test/security_solution_endpoint/page_objects/index.ts @@ -10,6 +10,7 @@ import { EndpointPolicyPageProvider } from './policy_page'; import { TrustedAppsPageProvider } from './trusted_apps_page'; import { EndpointPageUtils } from './page_utils'; import { IngestManagerCreatePackagePolicy } from './ingest_manager_create_package_policy_page'; +import { SecurityHostsPageProvider } from './hosts_page'; export const pageObjects = { ...xpackFunctionalPageObjects, @@ -18,4 +19,5 @@ export const pageObjects = { trustedApps: TrustedAppsPageProvider, endpointPageUtils: EndpointPageUtils, ingestManagerCreatePackagePolicy: IngestManagerCreatePackagePolicy, + hosts: SecurityHostsPageProvider, }; diff --git a/x-pack/test/security_solution_endpoint_api_int/apis/data_stream_helper.ts b/x-pack/test/security_solution_endpoint_api_int/apis/data_stream_helper.ts index be25f26532d9c..f1c05b2fc8f20 100644 --- a/x-pack/test/security_solution_endpoint_api_int/apis/data_stream_helper.ts +++ b/x-pack/test/security_solution_endpoint_api_int/apis/data_stream_helper.ts @@ -11,6 +11,7 @@ import { alertsIndexPattern, policyIndexPattern, metadataCurrentIndexPattern, + telemetryIndexPattern, } from '../../../plugins/security_solution/common/endpoint/constants'; export async function deleteDataStream(getService: (serviceName: 'es') => Client, index: string) { @@ -75,3 +76,7 @@ export async function deleteAlertsStream(getService: (serviceName: 'es') => Clie export async function deletePolicyStream(getService: (serviceName: 'es') => Client) { await deleteDataStream(getService, policyIndexPattern); } + +export async function deleteTelemetryStream(getService: (serviceName: 'es') => Client) { + await deleteDataStream(getService, telemetryIndexPattern); +} From c178c8abfffcd65452f70e5f7a03932cd7783417 Mon Sep 17 00:00:00 2001 From: Paul Tavares <56442535+paul-tavares@users.noreply.github.com> Date: Wed, 16 Sep 2020 16:21:31 -0400 Subject: [PATCH 06/19] [SECURITY_SOLUTION][ENDPOINT] Create Trusted Apps UI Flow (#77076) * Add UI for creating a new Trusted App entry --- .../common/endpoint/constants.ts | 1 + .../endpoint/schema/trusted_apps.test.ts | 62 +- .../common/endpoint/schema/trusted_apps.ts | 4 +- .../common/endpoint/types/trusted_apps.ts | 8 +- .../public/management/common/routing.ts | 27 +- .../components/administration_list_page.tsx | 10 +- .../pages/trusted_apps/service/index.ts | 14 +- .../state/trusted_apps_list_page_state.ts | 28 +- .../pages/trusted_apps/state/type_guards.ts | 55 + .../pages/trusted_apps/store/action.ts | 29 +- .../trusted_apps/store/middleware.test.ts | 5 + .../pages/trusted_apps/store/middleware.ts | 40 +- .../pages/trusted_apps/store/reducer.test.ts | 3 +- .../pages/trusted_apps/store/reducer.ts | 34 +- .../trusted_apps/store/selectors.test.ts | 90 +- .../pages/trusted_apps/store/selectors.ts | 55 +- .../pages/trusted_apps/test_utils/index.ts | 5 + .../management/pages/trusted_apps/types.ts | 11 + .../trusted_apps_page.test.tsx.snap | 968 +++++++++++++++++- .../components/create_trusted_app_flyout.tsx | 152 +++ .../components/create_trusted_app_form.tsx | 280 +++++ .../components/condition_entry.tsx | 188 ++++ .../components/condition_group.tsx | 61 ++ .../components/logical_condition/index.ts | 7 + .../logical_condition_builder.tsx | 89 ++ .../pages/trusted_apps/view/constants.ts | 20 + .../trusted_apps/view/trusted_apps_list.tsx | 13 +- .../view/trusted_apps_page.test.tsx | 266 ++++- .../trusted_apps/view/trusted_apps_page.tsx | 47 +- .../routes/trusted_apps/trusted_apps.test.ts | 6 +- 30 files changed, 2478 insertions(+), 100 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_flyout.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/components/condition_entry.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/components/condition_group.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/index.ts create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/logical_condition_builder.tsx create mode 100644 x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/constants.ts diff --git a/x-pack/plugins/security_solution/common/endpoint/constants.ts b/x-pack/plugins/security_solution/common/endpoint/constants.ts index a6018837fa4fe..cd59c2518794c 100644 --- a/x-pack/plugins/security_solution/common/endpoint/constants.ts +++ b/x-pack/plugins/security_solution/common/endpoint/constants.ts @@ -13,6 +13,7 @@ export const telemetryIndexPattern = 'metrics-endpoint.telemetry-*'; export const LIMITED_CONCURRENCY_ENDPOINT_ROUTE_TAG = 'endpoint:limited-concurrency'; export const LIMITED_CONCURRENCY_ENDPOINT_COUNT = 100; +export const TRUSTED_APPS_SUPPORTED_OS_TYPES: readonly string[] = ['macos', 'windows', 'linux']; export const TRUSTED_APPS_LIST_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_CREATE_API = '/api/endpoint/trusted_apps'; export const TRUSTED_APPS_DELETE_API = '/api/endpoint/trusted_apps/{id}'; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index b0c769216732d..fc94e9a7c312a 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -76,7 +76,7 @@ describe('When invoking Trusted Apps Schema', () => { os: 'windows', entries: [ { - field: 'path', + field: 'process.path', type: 'match', operator: 'included', value: 'c:/programs files/Anti-Virus', @@ -111,14 +111,6 @@ describe('When invoking Trusted Apps Schema', () => { expect(body.validate(bodyMsg)).toStrictEqual(bodyMsg); }); - it('should validate `description` to be non-empty if defined', () => { - const bodyMsg = { - ...getCreateTrustedAppItem(), - description: '', - }; - expect(() => body.validate(bodyMsg)).toThrow(); - }); - it('should validate `os` to to only accept known values', () => { const bodyMsg = { ...getCreateTrustedAppItem(), @@ -202,7 +194,7 @@ describe('When invoking Trusted Apps Schema', () => { }; expect(() => body.validate(bodyMsg2)).toThrow(); - ['hash', 'path'].forEach((field) => { + ['process.hash.*', 'process.path'].forEach((field) => { const bodyMsg3 = { ...getCreateTrustedAppItem(), entries: [ @@ -217,9 +209,55 @@ describe('When invoking Trusted Apps Schema', () => { }); }); - it.todo('should validate `entry.type` is limited to known values'); + it('should validate `entry.type` is limited to known values', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + type: 'invalid', + }, + ], + }; + expect(() => body.validate(bodyMsg)).toThrow(); + + // Allow `match` + const bodyMsg2 = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + type: 'match', + }, + ], + }; + expect(() => body.validate(bodyMsg2)).not.toThrow(); + }); + + it('should validate `entry.operator` is limited to known values', () => { + const bodyMsg = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + operator: 'invalid', + }, + ], + }; + expect(() => body.validate(bodyMsg)).toThrow(); - it.todo('should validate `entry.operator` is limited to known values'); + // Allow `match` + const bodyMsg2 = { + ...getCreateTrustedAppItem(), + entries: [ + { + ...getTrustedAppItemEntryItem(), + operator: 'included', + }, + ], + }; + expect(() => body.validate(bodyMsg2)).not.toThrow(); + }); it('should validate `entry.value` required', () => { const { value, ...entry } = getTrustedAppItemEntryItem(); diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 7c0de84b637c9..72e24a7d694d4 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -22,11 +22,11 @@ export const GetTrustedAppsRequestSchema = { export const PostTrustedAppCreateRequestSchema = { body: schema.object({ name: schema.string({ minLength: 1 }), - description: schema.maybe(schema.string({ minLength: 1 })), + description: schema.maybe(schema.string({ minLength: 0, defaultValue: '' })), os: schema.oneOf([schema.literal('linux'), schema.literal('macos'), schema.literal('windows')]), entries: schema.arrayOf( schema.object({ - field: schema.oneOf([schema.literal('hash'), schema.literal('path')]), + field: schema.oneOf([schema.literal('process.hash.*'), schema.literal('process.path')]), type: schema.literal('match'), operator: schema.literal('included'), value: schema.string({ minLength: 1 }), diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index 7aeb6c6024b99..3356fc67d2682 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -25,17 +25,17 @@ export interface PostTrustedAppCreateResponse { data: TrustedApp; } -interface MacosLinuxConditionEntry { - field: 'hash' | 'path'; +export interface MacosLinuxConditionEntry { + field: 'process.hash.*' | 'process.path'; type: 'match'; operator: 'included'; value: string; } -type WindowsConditionEntry = +export type WindowsConditionEntry = | MacosLinuxConditionEntry | (Omit & { - field: 'signer'; + field: 'process.code_signature'; }); /** Type for a new Trusted App Entry */ diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 62f360df90192..40320ed794203 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -21,6 +21,7 @@ import { import { AdministrationSubTab } from '../types'; import { appendSearch } from '../../common/components/link_to/helpers'; import { EndpointIndexUIQueryParams } from '../pages/endpoint_hosts/types'; +import { TrustedAppsUrlParams } from '../pages/trusted_apps/types'; // Taken from: https://github.com/microsoft/TypeScript/issues/12936#issuecomment-559034150 type ExactKeys = Exclude extends never ? T1 : never; @@ -89,18 +90,16 @@ export const getPolicyDetailPath = (policyId: string, search?: string) => { })}${appendSearch(search)}`; }; -interface ListPaginationParams { - page_index: number; - page_size: number; -} - -const isDefaultOrMissing = (value: number | undefined, defaultValue: number) => { +const isDefaultOrMissing = ( + value: number | string | undefined, + defaultValue: number | undefined +) => { return value === undefined || value === defaultValue; }; const normalizeListPaginationParams = ( - params?: Partial -): Partial => { + params?: Partial +): Partial => { if (params) { return { ...(!isDefaultOrMissing(params.page_index, MANAGEMENT_DEFAULT_PAGE) @@ -109,13 +108,19 @@ const normalizeListPaginationParams = ( ...(!isDefaultOrMissing(params.page_size, MANAGEMENT_DEFAULT_PAGE_SIZE) ? { page_size: params.page_size } : {}), + ...(!isDefaultOrMissing(params.show, undefined) ? { show: params.show } : {}), }; } else { return {}; } }; -const extractFirstParamValue = (query: querystring.ParsedUrlQuery, key: string): string => { +/** + * Given an object with url params, and a given key, return back only the first param value (case multiples were defined) + * @param query + * @param key + */ +export const extractFirstParamValue = (query: querystring.ParsedUrlQuery, key: string): string => { const value = query[key]; return Array.isArray(value) ? value[value.length - 1] : value; @@ -135,12 +140,12 @@ const extractPageSize = (query: querystring.ParsedUrlQuery): number => { export const extractListPaginationParams = ( query: querystring.ParsedUrlQuery -): ListPaginationParams => ({ +): TrustedAppsUrlParams => ({ page_index: extractPageIndex(query), page_size: extractPageSize(query), }); -export const getTrustedAppsListPath = (params?: Partial): string => { +export const getTrustedAppsListPath = (params?: Partial): string => { const path = generatePath(MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, { tabName: AdministrationSubTab.trustedApps, }); diff --git a/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx b/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx index 3df525b4d59d6..372916581b35d 100644 --- a/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx +++ b/x-pack/plugins/security_solution/public/management/components/administration_list_page.tsx @@ -5,6 +5,7 @@ */ import React, { FC, memo } from 'react'; import { EuiPanel, EuiSpacer, CommonProps } from '@elastic/eui'; +import styled from 'styled-components'; import { SecurityPageName } from '../../../common/constants'; import { WrapperPage } from '../../common/components/wrapper_page'; import { HeaderPage } from '../../common/components/header_page'; @@ -14,6 +15,13 @@ import { AdministrationSubTab } from '../types'; import { ENDPOINTS_TAB, TRUSTED_APPS_TAB, BETA_BADGE_LABEL } from '../common/translations'; import { getEndpointListPath, getTrustedAppsListPath } from '../common/routing'; +/** Ensure that all flyouts z-index in Administation area show the flyout header */ +const EuiPanelStyled = styled(EuiPanel)` + .euiFlyout { + z-index: ${({ theme }) => theme.eui.euiZNavigation + 1}; + } +`; + interface AdministrationListPageProps { beta: boolean; title: React.ReactNode; @@ -54,7 +62,7 @@ export const AdministrationListPage: FC - {children} + {children} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts index 9308c137cfb9c..a3c5911aa3a86 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/service/index.ts @@ -5,14 +5,20 @@ */ import { HttpStart } from 'kibana/public'; -import { TRUSTED_APPS_LIST_API } from '../../../../../common/endpoint/constants'; +import { + TRUSTED_APPS_CREATE_API, + TRUSTED_APPS_LIST_API, +} from '../../../../../common/endpoint/constants'; import { GetTrustedListAppsResponse, GetTrustedAppsListRequest, + PostTrustedAppCreateRequest, + PostTrustedAppCreateResponse, } from '../../../../../common/endpoint/types/trusted_apps'; export interface TrustedAppsService { getTrustedAppsList(request: GetTrustedAppsListRequest): Promise; + createTrustedApp(request: PostTrustedAppCreateRequest): Promise; } export class TrustedAppsHttpService implements TrustedAppsService { @@ -23,4 +29,10 @@ export class TrustedAppsHttpService implements TrustedAppsService { query: request, }); } + + async createTrustedApp(request: PostTrustedAppCreateRequest) { + return this.http.post(TRUSTED_APPS_CREATE_API, { + body: JSON.stringify(request), + }); + } } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts index 23f4cfd576c56..071557ec1a815 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts @@ -4,8 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; +import { NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types/trusted_apps'; import { AsyncResourceState } from '.'; +import { TrustedAppsUrlParams } from '../types'; +import { ServerApiError } from '../../../../common/types'; export interface PaginationInfo { index: number; @@ -18,10 +20,34 @@ export interface TrustedAppsListData { paginationInfo: PaginationInfo; } +/** Store State when an API request has been sent to create a new trusted app entry */ +export interface TrustedAppCreatePending { + type: 'pending'; + data: NewTrustedApp; +} + +/** Store State when creation of a new Trusted APP entry was successful */ +export interface TrustedAppCreateSuccess { + type: 'success'; + data: TrustedApp; +} + +/** Store State when creation of a new Trusted App Entry failed */ +export interface TrustedAppCreateFailure { + type: 'failure'; + data: ServerApiError; +} + export interface TrustedAppsListPageState { listView: { currentListResourceState: AsyncResourceState; currentPaginationInfo: PaginationInfo; + show: TrustedAppsUrlParams['show'] | undefined; }; + createView: + | undefined + | TrustedAppCreatePending + | TrustedAppCreateSuccess + | TrustedAppCreateFailure; active: boolean; } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts new file mode 100644 index 0000000000000..1e8e0bc042b86 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/type_guards.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + TrustedAppCreatePending, + TrustedAppsListPageState, + TrustedAppCreateFailure, + TrustedAppCreateSuccess, +} from './trusted_apps_list_page_state'; +import { + Immutable, + NewTrustedApp, + WindowsConditionEntry, +} from '../../../../../common/endpoint/types'; +import { TRUSTED_APPS_SUPPORTED_OS_TYPES } from '../../../../../common/endpoint/constants'; + +type CreateViewPossibleStates = + | TrustedAppsListPageState['createView'] + | Immutable; + +export const isTrustedAppCreatePendingState = ( + data: CreateViewPossibleStates +): data is TrustedAppCreatePending => { + return data?.type === 'pending'; +}; + +export const isTrustedAppCreateSuccessState = ( + data: CreateViewPossibleStates +): data is TrustedAppCreateSuccess => { + return data?.type === 'success'; +}; + +export const isTrustedAppCreateFailureState = ( + data: CreateViewPossibleStates +): data is TrustedAppCreateFailure => { + return data?.type === 'failure'; +}; + +export const isWindowsTrustedApp = ( + trustedApp: T +): trustedApp is T & { os: 'windows' } => { + return trustedApp.os === 'windows'; +}; + +export const isWindowsTrustedAppCondition = (condition: { + field: string; +}): condition is WindowsConditionEntry => { + return condition.field === 'process.code_signature' || true; +}; + +export const isTrustedAppSupportedOs = (os: string): os is NewTrustedApp['os'] => + TRUSTED_APPS_SUPPORTED_OS_TYPES.includes(os); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts index 2154a0eca462e..3a43ffe58262c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/action.ts @@ -4,7 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AsyncResourceState, TrustedAppsListData } from '../state'; +import { + AsyncResourceState, + TrustedAppCreateFailure, + TrustedAppCreatePending, + TrustedAppCreateSuccess, + TrustedAppsListData, +} from '../state'; export interface TrustedAppsListResourceStateChanged { type: 'trustedAppsListResourceStateChanged'; @@ -13,4 +19,23 @@ export interface TrustedAppsListResourceStateChanged { }; } -export type TrustedAppsPageAction = TrustedAppsListResourceStateChanged; +export interface UserClickedSaveNewTrustedAppButton { + type: 'userClickedSaveNewTrustedAppButton'; + payload: TrustedAppCreatePending; +} + +export interface ServerReturnedCreateTrustedAppSuccess { + type: 'serverReturnedCreateTrustedAppSuccess'; + payload: TrustedAppCreateSuccess; +} + +export interface ServerReturnedCreateTrustedAppFailure { + type: 'serverReturnedCreateTrustedAppFailure'; + payload: TrustedAppCreateFailure; +} + +export type TrustedAppsPageAction = + | TrustedAppsListResourceStateChanged + | UserClickedSaveNewTrustedAppButton + | ServerReturnedCreateTrustedAppSuccess + | ServerReturnedCreateTrustedAppFailure; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts index c5abaae473486..e5f00ee0ccf81 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -31,6 +31,7 @@ const createGetTrustedListAppsResponse = (pagination: PaginationInfo, totalItems const createTrustedAppsServiceMock = (): jest.Mocked => ({ getTrustedAppsList: jest.fn(), + createTrustedApp: jest.fn(), }); const createStoreSetup = (trustedAppsService: TrustedAppsService) => { @@ -70,6 +71,7 @@ describe('middleware', () => { expect(store.getState()).toStrictEqual({ listView: createLoadingListViewWithPagination(pagination), active: true, + createView: undefined, }); await spyMiddleware.waitForAction('trustedAppsListResourceStateChanged'); @@ -77,6 +79,7 @@ describe('middleware', () => { expect(store.getState()).toStrictEqual({ listView: createLoadedListViewWithPagination(pagination, pagination, 500), active: true, + createView: undefined, }); }); @@ -99,6 +102,7 @@ describe('middleware', () => { expect(store.getState()).toStrictEqual({ listView: createLoadedListViewWithPagination(pagination, pagination, 500), active: true, + createView: undefined, }); }); @@ -118,6 +122,7 @@ describe('middleware', () => { createServerApiError('Internal Server Error') ), active: true, + createView: undefined, }); const infiniteLoopTest = async () => { diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts index 31c301b8dbd2b..bf9cacff5caf0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Immutable } from '../../../../../common/endpoint/types'; +import { Immutable, PostTrustedAppCreateRequest } from '../../../../../common/endpoint/types'; import { AppAction } from '../../../../common/store/actions'; import { ImmutableMiddleware, @@ -28,6 +28,8 @@ import { getLastLoadedListResourceState, getListCurrentPageIndex, getListCurrentPageSize, + getTrustedAppCreateData, + isCreatePending, needsRefreshOfListData, } from './selectors'; @@ -81,6 +83,38 @@ const refreshList = async ( } }; +const createTrustedApp = async ( + store: ImmutableMiddlewareAPI, + trustedAppsService: TrustedAppsService +) => { + const { dispatch, getState } = store; + + if (isCreatePending(getState())) { + try { + const newTrustedApp = getTrustedAppCreateData(getState()); + const createdTrustedApp = ( + await trustedAppsService.createTrustedApp(newTrustedApp as PostTrustedAppCreateRequest) + ).data; + dispatch({ + type: 'serverReturnedCreateTrustedAppSuccess', + payload: { + type: 'success', + data: createdTrustedApp, + }, + }); + refreshList(store, trustedAppsService); + } catch (error) { + dispatch({ + type: 'serverReturnedCreateTrustedAppFailure', + payload: { + type: 'failure', + data: error.body || error, + }, + }); + } + } +}; + export const createTrustedAppsPageMiddleware = ( trustedAppsService: TrustedAppsService ): ImmutableMiddleware => { @@ -91,6 +125,10 @@ export const createTrustedAppsPageMiddleware = ( if (action.type === 'userChangedUrl' && needsRefreshOfListData(store.getState())) { await refreshList(store, trustedAppsService); } + + if (action.type === 'userClickedSaveNewTrustedAppButton') { + createTrustedApp(store, trustedAppsService); + } }; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts index 34325e0cf1398..76dd4b48e63d2 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts @@ -26,6 +26,7 @@ describe('reducer', () => { currentPaginationInfo: { index: 5, size: 50 }, }, active: true, + createView: undefined, }); }); @@ -67,7 +68,7 @@ describe('reducer', () => { it('makes page state inactive and resets list to uninitialised state when navigating away', () => { const result = trustedAppsPageReducer( - { listView: createLoadedListViewWithPagination(), active: true }, + { listView: createLoadedListViewWithPagination(), active: true, createView: undefined }, createUserChangedUrlAction('/endpoints') ); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts index 4fdc6f90ef40c..d824a6e95c8d5 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts @@ -11,14 +11,19 @@ import { ImmutableReducer } from '../../../../common/store'; import { AppLocation, Immutable } from '../../../../../common/endpoint/types'; import { UserChangedUrl } from '../../../../common/store/routing/action'; import { AppAction } from '../../../../common/store/actions'; -import { extractListPaginationParams } from '../../../common/routing'; +import { extractFirstParamValue, extractListPaginationParams } from '../../../common/routing'; import { MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE, } from '../../../common/constants'; -import { TrustedAppsListResourceStateChanged } from './action'; +import { + ServerReturnedCreateTrustedAppFailure, + ServerReturnedCreateTrustedAppSuccess, + TrustedAppsListResourceStateChanged, + UserClickedSaveNewTrustedAppButton, +} from './action'; import { TrustedAppsListPageState } from '../state'; type StateReducer = ImmutableReducer; @@ -51,7 +56,10 @@ const trustedAppsListResourceStateChanged: CaseReducer = (state, action) => { if (isTrustedAppsPageLocation(action.payload)) { - const paginationParams = extractListPaginationParams(parse(action.payload.search.slice(1))); + const parsedUrlsParams = parse(action.payload.search.slice(1)); + const paginationParams = extractListPaginationParams(parsedUrlsParams); + const show = + extractFirstParamValue(parsedUrlsParams, 'show') === 'create' ? 'create' : undefined; return { ...state, @@ -61,7 +69,9 @@ const userChangedUrl: CaseReducer = (state, action) => { index: paginationParams.page_index, size: paginationParams.page_size, }, + show, }, + createView: show ? state.createView : undefined, active: true, }; } else { @@ -69,6 +79,17 @@ const userChangedUrl: CaseReducer = (state, action) => { } }; +const trustedAppsCreateResourceChanged: CaseReducer< + | UserClickedSaveNewTrustedAppButton + | ServerReturnedCreateTrustedAppFailure + | ServerReturnedCreateTrustedAppSuccess +> = (state, action) => { + return { + ...state, + createView: action.payload, + }; +}; + export const initialTrustedAppsPageState: TrustedAppsListPageState = { listView: { currentListResourceState: { type: 'UninitialisedResourceState' }, @@ -76,7 +97,9 @@ export const initialTrustedAppsPageState: TrustedAppsListPageState = { index: MANAGEMENT_DEFAULT_PAGE, size: MANAGEMENT_DEFAULT_PAGE_SIZE, }, + show: undefined, }, + createView: undefined, active: false, }; @@ -90,6 +113,11 @@ export const trustedAppsPageReducer: StateReducer = ( case 'userChangedUrl': return userChangedUrl(state, action); + + case 'userClickedSaveNewTrustedAppButton': + case 'serverReturnedCreateTrustedAppSuccess': + case 'serverReturnedCreateTrustedAppFailure': + return trustedAppsCreateResourceChanged(state, action); } return state; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts index a969e2dee4773..453afa1befa6b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts @@ -30,33 +30,41 @@ import { describe('selectors', () => { describe('needsRefreshOfListData()', () => { it('returns false for outdated resource state and inactive state', () => { - expect(needsRefreshOfListData({ listView: createDefaultListView(), active: false })).toBe( - false - ); + expect( + needsRefreshOfListData({ + listView: createDefaultListView(), + active: false, + createView: undefined, + }) + ).toBe(false); }); it('returns true for outdated resource state and active state', () => { - expect(needsRefreshOfListData({ listView: createDefaultListView(), active: true })).toBe( - true - ); + expect( + needsRefreshOfListData({ + listView: createDefaultListView(), + active: true, + createView: undefined, + }) + ).toBe(true); }); it('returns true when current loaded page index is outdated', () => { const listView = createLoadedListViewWithPagination({ index: 1, size: 20 }); - expect(needsRefreshOfListData({ listView, active: true })).toBe(true); + expect(needsRefreshOfListData({ listView, active: true, createView: undefined })).toBe(true); }); it('returns true when current loaded page size is outdated', () => { const listView = createLoadedListViewWithPagination({ index: 0, size: 50 }); - expect(needsRefreshOfListData({ listView, active: true })).toBe(true); + expect(needsRefreshOfListData({ listView, active: true, createView: undefined })).toBe(true); }); it('returns false when current loaded data is up to date', () => { const listView = createLoadedListViewWithPagination(); - expect(needsRefreshOfListData({ listView, active: true })).toBe(false); + expect(needsRefreshOfListData({ listView, active: true, createView: undefined })).toBe(false); }); }); @@ -64,9 +72,9 @@ describe('selectors', () => { it('returns current list resource state', () => { const listView = createDefaultListView(); - expect(getCurrentListResourceState({ listView, active: false })).toStrictEqual( - createUninitialisedResourceState() - ); + expect( + getCurrentListResourceState({ listView, active: false, createView: undefined }) + ).toStrictEqual(createUninitialisedResourceState()); }); }); @@ -78,17 +86,20 @@ describe('selectors', () => { 200 ), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(getLastLoadedListResourceState({ listView, active: false })).toStrictEqual( - createListLoadedResourceState(createDefaultPaginationInfo(), 200) - ); + expect( + getLastLoadedListResourceState({ listView, active: false, createView: undefined }) + ).toStrictEqual(createListLoadedResourceState(createDefaultPaginationInfo(), 200)); }); }); describe('getListItems()', () => { it('returns empty list when no valid data loaded', () => { - expect(getListItems({ listView: createDefaultListView(), active: false })).toStrictEqual([]); + expect( + getListItems({ listView: createDefaultListView(), active: false, createView: undefined }) + ).toStrictEqual([]); }); it('returns last loaded list items', () => { @@ -98,9 +109,10 @@ describe('selectors', () => { 200 ), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(getListItems({ listView, active: false })).toStrictEqual( + expect(getListItems({ listView, active: false, createView: undefined })).toStrictEqual( createSampleTrustedApps(createDefaultPaginationInfo()) ); }); @@ -108,7 +120,13 @@ describe('selectors', () => { describe('getListTotalItemsCount()', () => { it('returns 0 when no valid data loaded', () => { - expect(getListTotalItemsCount({ listView: createDefaultListView(), active: false })).toBe(0); + expect( + getListTotalItemsCount({ + listView: createDefaultListView(), + active: false, + createView: undefined, + }) + ).toBe(0); }); it('returns last loaded total items count', () => { @@ -118,21 +136,34 @@ describe('selectors', () => { 200 ), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(getListTotalItemsCount({ listView, active: false })).toBe(200); + expect(getListTotalItemsCount({ listView, active: false, createView: undefined })).toBe(200); }); }); describe('getListCurrentPageIndex()', () => { it('returns page index', () => { - expect(getListCurrentPageIndex({ listView: createDefaultListView(), active: false })).toBe(0); + expect( + getListCurrentPageIndex({ + listView: createDefaultListView(), + active: false, + createView: undefined, + }) + ).toBe(0); }); }); describe('getListCurrentPageSize()', () => { it('returns page index', () => { - expect(getListCurrentPageSize({ listView: createDefaultListView(), active: false })).toBe(20); + expect( + getListCurrentPageSize({ + listView: createDefaultListView(), + active: false, + createView: undefined, + }) + ).toBe(20); }); }); @@ -144,24 +175,32 @@ describe('selectors', () => { 200 ), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(getListErrorMessage({ listView, active: false })).toBeUndefined(); + expect( + getListErrorMessage({ listView, active: false, createView: undefined }) + ).toBeUndefined(); }); it('returns message when not in failed state', () => { const listView = { currentListResourceState: createListFailedResourceState('Internal Server Error'), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(getListErrorMessage({ listView, active: false })).toBe('Internal Server Error'); + expect(getListErrorMessage({ listView, active: false, createView: undefined })).toBe( + 'Internal Server Error' + ); }); }); describe('isListLoading()', () => { it('returns false when no loading is happening', () => { - expect(isListLoading({ listView: createDefaultListView(), active: false })).toBe(false); + expect( + isListLoading({ listView: createDefaultListView(), active: false, createView: undefined }) + ).toBe(false); }); it('returns true when loading is in progress', () => { @@ -171,9 +210,10 @@ describe('selectors', () => { 200 ), currentPaginationInfo: createDefaultPaginationInfo(), + show: undefined, }; - expect(isListLoading({ listView, active: false })).toBe(true); + expect(isListLoading({ listView, active: false, createView: undefined })).toBe(true); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts index 6fde779ac1cce..f074b21f79f4e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { Immutable, TrustedApp } from '../../../../../common/endpoint/types'; +import { createSelector } from 'reselect'; +import { Immutable, NewTrustedApp, TrustedApp } from '../../../../../common/endpoint/types'; import { AsyncResourceState, @@ -14,9 +15,16 @@ import { isOutdatedResourceState, LoadedResourceState, PaginationInfo, + TrustedAppCreateFailure, TrustedAppsListData, TrustedAppsListPageState, } from '../state'; +import { TrustedAppsUrlParams } from '../types'; +import { + isTrustedAppCreateFailureState, + isTrustedAppCreatePendingState, + isTrustedAppCreateSuccessState, +} from '../state/type_guards'; const pageInfosEqual = (pageInfo1: PaginationInfo, pageInfo2: PaginationInfo): boolean => pageInfo1.index === pageInfo2.index && pageInfo1.size === pageInfo2.size; @@ -65,6 +73,27 @@ export const getListTotalItemsCount = (state: Immutable +) => TrustedAppsListPageState['listView']['show'] = (state) => { + return state.listView.show; +}; + +export const getListUrlSearchParams: ( + state: Immutable +) => TrustedAppsUrlParams = createSelector( + getListCurrentPageIndex, + getListCurrentPageSize, + getListCurrentShowValue, + (pageIndex, pageSize, showValue) => { + return { + page_index: pageIndex, + page_size: pageSize, + show: showValue, + }; + } +); + export const getListErrorMessage = ( state: Immutable ): string | undefined => { @@ -74,3 +103,27 @@ export const getListErrorMessage = ( export const isListLoading = (state: Immutable): boolean => { return isLoadingResourceState(state.listView.currentListResourceState); }; + +export const isCreatePending: (state: Immutable) => boolean = ({ + createView, +}) => { + return isTrustedAppCreatePendingState(createView); +}; + +export const getTrustedAppCreateData: ( + state: Immutable +) => undefined | Immutable = ({ createView }) => { + return (isTrustedAppCreatePendingState(createView) && createView.data) || undefined; +}; + +export const getApiCreateErrors: ( + state: Immutable +) => undefined | TrustedAppCreateFailure['data'] = ({ createView }) => { + return (isTrustedAppCreateFailureState(createView) && createView.data) || undefined; +}; + +export const wasCreateSuccessful: (state: Immutable) => boolean = ({ + createView, +}) => { + return isTrustedAppCreateSuccessState(createView); +}; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts index fab059a422a2a..70e4e1e685b01 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/test_utils/index.ts @@ -21,6 +21,7 @@ import { } from '../state'; import { TrustedAppsListResourceStateChanged } from '../store/action'; +import { initialTrustedAppsPageState } from '../store/reducer'; const OS_LIST: Array = ['windows', 'macos', 'linux']; @@ -93,6 +94,7 @@ export const createListComplexLoadingResourceState = ( export const createDefaultPaginationInfo = () => ({ index: 0, size: 20 }); export const createDefaultListView = () => ({ + ...initialTrustedAppsPageState.listView, currentListResourceState: createUninitialisedResourceState(), currentPaginationInfo: createDefaultPaginationInfo(), }); @@ -101,6 +103,7 @@ export const createLoadingListViewWithPagination = ( currentPaginationInfo: PaginationInfo, previousState: StaleResourceState = createUninitialisedResourceState() ): TrustedAppsListPageState['listView'] => ({ + ...initialTrustedAppsPageState.listView, currentListResourceState: { type: 'LoadingResourceState', previousState }, currentPaginationInfo, }); @@ -110,6 +113,7 @@ export const createLoadedListViewWithPagination = ( currentPaginationInfo: PaginationInfo = createDefaultPaginationInfo(), totalItemsCount: number = 200 ): TrustedAppsListPageState['listView'] => ({ + ...initialTrustedAppsPageState.listView, currentListResourceState: createListLoadedResourceState(paginationInfo, totalItemsCount), currentPaginationInfo, }); @@ -119,6 +123,7 @@ export const createFailedListViewWithPagination = ( error: ServerApiError, lastLoadedState?: LoadedResourceState ): TrustedAppsListPageState['listView'] => ({ + ...initialTrustedAppsPageState.listView, currentListResourceState: { type: 'FailedResourceState', error, lastLoadedState }, currentPaginationInfo, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts new file mode 100644 index 0000000000000..4d59cd7913a0c --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface TrustedAppsUrlParams { + page_index: number; + page_size: number; + show?: 'create'; +} diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap index d6e9aee108cf6..5cb788d96f3e7 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/__snapshots__/trusted_apps_page.test.tsx.snap @@ -1,23 +1,959 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`TrustedAppsPage rendering 1`] = ` - +Object { + "asFragment": [Function], + "baseElement": .c0 { + padding: 24px; +} + +.c0.siemWrapperPage--restrictWidthDefault, +.c0.siemWrapperPage--restrictWidthCustom { + box-sizing: content-box; + margin: 0 auto; +} + +.c0.siemWrapperPage--restrictWidthDefault { + max-width: 1000px; +} + +.c0.siemWrapperPage--fullHeight { + height: 100%; +} + +.c0.siemWrapperPage--withTimeline { + padding-right: 70px; +} + +.c0.siemWrapperPage--noPadding { + padding: 0; +} + +.c4 { + margin-top: 8px; +} + +.c4 .siemSubtitle__item { + color: #6a717d; + font-size: 12px; + line-height: 1.5; +} + +.c3 { + vertical-align: middle; +} + +.c1 { + margin-bottom: 24px; +} + +.c2 { + display: block; +} + +.c5 .euiFlyout { + z-index: 4001; +} + +@media only screen and (min-width:575px) { + .c4 .siemSubtitle__item { + display: inline-block; + margin-right: 16px; } - title={ - + + .c4 .siemSubtitle__item:last-child { + margin-right: 0; } +} + + +
+
+
+
+
+

+ Trusted Applications + + + Beta + +

+
+
+ View and configure trusted applications +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ + No items found + +
+
+
+
+
+
+
+ , + "container":
+
+
+
+
+

+ Trusted Applications + + + Beta + +

+
+
+ View and configure trusted applications +
+
+
+
+ +
+
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + +
+
+
+ + Name + +
+
+
+ + OS + +
+
+
+ + Date Created + +
+
+
+ + Created By + +
+
+
+ + No items found + +
+
+
+
+
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`TrustedAppsPage when the Add Trusted App button is clicked should display create form 1`] = ` +@media only screen and (min-width:575px) { + +} + +
- - +
+
+ +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: Windows, is selected + + +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+ + Select an option: Hash, is selected + + +
+ + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+
+