From 8325222c0a86f0b6e09e1380ca55f93c26f1017f Mon Sep 17 00:00:00 2001 From: Michael Olorunnisola Date: Mon, 13 Jul 2020 20:52:25 -0400 Subject: [PATCH 01/57] initial telemetry setup (#69330) --- .../security_solution/server/plugin.ts | 1 + .../server/usage/collector.ts | 45 ++++- .../{ => detections}/detections.mocks.ts | 2 +- .../usage/{ => detections}/detections.test.ts | 16 +- .../{ => detections}/detections_helpers.ts | 14 +- .../{detections.ts => detections/index.ts} | 4 +- .../server/usage/endpoints/endpoint.mocks.ts | 131 +++++++++++++++ .../server/usage/endpoints/endpoint.test.ts | 116 +++++++++++++ .../usage/endpoints/fleet_saved_objects.ts | 37 ++++ .../server/usage/endpoints/index.ts | 159 ++++++++++++++++++ .../security_solution/server/usage/types.ts | 3 +- .../schema/xpack_plugins.json | 43 +++++ 12 files changed, 546 insertions(+), 25 deletions(-) rename x-pack/plugins/security_solution/server/usage/{ => detections}/detections.mocks.ts (98%) rename x-pack/plugins/security_solution/server/usage/{ => detections}/detections.test.ts (83%) rename x-pack/plugins/security_solution/server/usage/{ => detections}/detections_helpers.ts (91%) rename x-pack/plugins/security_solution/server/usage/{detections.ts => detections/index.ts} (89%) create mode 100644 x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts create mode 100644 x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts create mode 100644 x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts create mode 100644 x-pack/plugins/security_solution/server/usage/endpoints/index.ts diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index ebd95fe79ebf58..137c57f04367d8 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -114,6 +114,7 @@ export class Plugin implements IPlugin void; export interface UsageData { detections: DetectionsUsage; + endpoints: EndpointUsage; } -export const registerCollector: RegisterCollector = ({ kibanaIndex, ml, usageCollection }) => { +export async function getInternalSavedObjectsClient(core: CoreSetup) { + return core.getStartServices().then(async ([coreStart]) => { + return coreStart.savedObjects.createInternalRepository(); + }); +} + +export const registerCollector: RegisterCollector = ({ + core, + kibanaIndex, + ml, + usageCollection, +}) => { if (!usageCollection) { return; } - const collector = usageCollection.makeUsageCollector({ type: 'security_solution', schema: { @@ -43,11 +55,32 @@ export const registerCollector: RegisterCollector = ({ kibanaIndex, ml, usageCol }, }, }, + endpoints: { + total_installed: { type: 'long' }, + active_within_last_24_hours: { type: 'long' }, + os: { + full_name: { type: 'keyword' }, + platform: { type: 'keyword' }, + version: { type: 'keyword' }, + count: { type: 'long' }, + }, + policies: { + malware: { + success: { type: 'long' }, + warning: { type: 'long' }, + failure: { type: 'long' }, + }, + }, + }, }, isReady: () => kibanaIndex.length > 0, - fetch: async (callCluster: LegacyAPICaller): Promise => ({ - detections: await fetchDetectionsUsage(kibanaIndex, callCluster, ml), - }), + fetch: async (callCluster: LegacyAPICaller): Promise => { + const savedObjectsClient = await getInternalSavedObjectsClient(core); + return { + detections: await fetchDetectionsUsage(kibanaIndex, callCluster, ml), + endpoints: await getEndpointTelemetryFromFleet(savedObjectsClient), + }; + }, }); usageCollection.registerCollector(collector); diff --git a/x-pack/plugins/security_solution/server/usage/detections.mocks.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts similarity index 98% rename from x-pack/plugins/security_solution/server/usage/detections.mocks.ts rename to x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts index c80dc6936ec7b5..e59b1092978daf 100644 --- a/x-pack/plugins/security_solution/server/usage/detections.mocks.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.mocks.ts @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { INTERNAL_IMMUTABLE_KEY } from '../../common/constants'; +import { INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; export const getMockJobSummaryResponse = () => [ { diff --git a/x-pack/plugins/security_solution/server/usage/detections.test.ts b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts similarity index 83% rename from x-pack/plugins/security_solution/server/usage/detections.test.ts rename to x-pack/plugins/security_solution/server/usage/detections/detections.test.ts index 7fd2d3eb9ff270..0fc23f90a0ebf6 100644 --- a/x-pack/plugins/security_solution/server/usage/detections.test.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections.test.ts @@ -4,20 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from '../../../../../src/core/server'; -import { elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; -import { jobServiceProvider } from '../../../ml/server/models/job_service'; -import { DataRecognizer } from '../../../ml/server/models/data_recognizer'; -import { mlServicesMock } from '../lib/machine_learning/mocks'; +import { LegacyAPICaller } from '../../../../../../src/core/server'; +import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; +import { jobServiceProvider } from '../../../../ml/server/models/job_service'; +import { DataRecognizer } from '../../../../ml/server/models/data_recognizer'; +import { mlServicesMock } from '../../lib/machine_learning/mocks'; import { getMockJobSummaryResponse, getMockListModulesResponse, getMockRulesResponse, } from './detections.mocks'; -import { fetchDetectionsUsage } from './detections'; +import { fetchDetectionsUsage } from './index'; -jest.mock('../../../ml/server/models/job_service'); -jest.mock('../../../ml/server/models/data_recognizer'); +jest.mock('../../../../ml/server/models/job_service'); +jest.mock('../../../../ml/server/models/data_recognizer'); describe('Detections Usage', () => { describe('fetchDetectionsUsage()', () => { diff --git a/x-pack/plugins/security_solution/server/usage/detections_helpers.ts b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts similarity index 91% rename from x-pack/plugins/security_solution/server/usage/detections_helpers.ts rename to x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts index 18a90b12991b2f..3d04c24bab55aa 100644 --- a/x-pack/plugins/security_solution/server/usage/detections_helpers.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/detections_helpers.ts @@ -6,15 +6,15 @@ import { SearchParams } from 'elasticsearch'; -import { LegacyAPICaller, SavedObjectsClient } from '../../../../../src/core/server'; +import { LegacyAPICaller, SavedObjectsClient } from '../../../../../../src/core/server'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { jobServiceProvider } from '../../../ml/server/models/job_service'; +import { jobServiceProvider } from '../../../../ml/server/models/job_service'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { DataRecognizer } from '../../../ml/server/models/data_recognizer'; -import { MlPluginSetup } from '../../../ml/server'; -import { SIGNALS_ID, INTERNAL_IMMUTABLE_KEY } from '../../common/constants'; -import { DetectionRulesUsage, MlJobsUsage } from './detections'; -import { isJobStarted } from '../../common/machine_learning/helpers'; +import { DataRecognizer } from '../../../../ml/server/models/data_recognizer'; +import { MlPluginSetup } from '../../../../ml/server'; +import { SIGNALS_ID, INTERNAL_IMMUTABLE_KEY } from '../../../common/constants'; +import { DetectionRulesUsage, MlJobsUsage } from './index'; +import { isJobStarted } from '../../../common/machine_learning/helpers'; interface DetectionsMetric { isElastic: boolean; diff --git a/x-pack/plugins/security_solution/server/usage/detections.ts b/x-pack/plugins/security_solution/server/usage/detections/index.ts similarity index 89% rename from x-pack/plugins/security_solution/server/usage/detections.ts rename to x-pack/plugins/security_solution/server/usage/detections/index.ts index 1475a8ae346257..dd50e79e22cc90 100644 --- a/x-pack/plugins/security_solution/server/usage/detections.ts +++ b/x-pack/plugins/security_solution/server/usage/detections/index.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyAPICaller } from '../../../../../src/core/server'; +import { LegacyAPICaller } from '../../../../../../src/core/server'; import { getMlJobsUsage, getRulesUsage } from './detections_helpers'; -import { MlPluginSetup } from '../../../ml/server'; +import { MlPluginSetup } from '../../../../ml/server'; interface FeatureUsage { enabled: number; diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts new file mode 100644 index 00000000000000..f41cfb773736d7 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.mocks.ts @@ -0,0 +1,131 @@ +/* + * 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 { SavedObjectsFindResponse } from 'src/core/server'; +import { AgentEventSOAttributes } from './../../../../ingest_manager/common/types/models/agent'; +import { + AGENT_SAVED_OBJECT_TYPE, + AGENT_EVENT_SAVED_OBJECT_TYPE, +} from '../../../../ingest_manager/common/constants/agent'; +import { Agent } from '../../../../ingest_manager/common'; +import { FLEET_ENDPOINT_PACKAGE_CONSTANT } from './fleet_saved_objects'; + +const testAgentId = 'testAgentId'; +const testConfigId = 'testConfigId'; + +/** Mock OS Platform for endpoint telemetry */ +export const MockOSPlatform = 'somePlatform'; +/** Mock OS Name for endpoint telemetry */ +export const MockOSName = 'somePlatformName'; +/** Mock OS Version for endpoint telemetry */ +export const MockOSVersion = '1'; +/** Mock OS Full Name for endpoint telemetry */ +export const MockOSFullName = 'somePlatformFullName'; + +/** + * + * @param lastCheckIn - the last time the agent checked in. Defaults to current ISO time. + * @description We request the install and OS related telemetry information from the 'fleet-agents' saved objects in ingest_manager. This mocks that response + */ +export const mockFleetObjectsResponse = ( + lastCheckIn = new Date().toISOString() +): SavedObjectsFindResponse => ({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: AGENT_SAVED_OBJECT_TYPE, + id: testAgentId, + attributes: { + active: true, + id: testAgentId, + config_id: 'randoConfigId', + type: 'PERMANENT', + user_provided_metadata: {}, + enrolled_at: lastCheckIn, + current_error_events: [], + local_metadata: { + elastic: { + agent: { + id: testAgentId, + }, + }, + host: { + hostname: 'testDesktop', + name: 'testDesktop', + id: 'randoHostId', + }, + os: { + platform: MockOSPlatform, + version: MockOSVersion, + name: MockOSName, + full: MockOSFullName, + }, + }, + packages: [FLEET_ENDPOINT_PACKAGE_CONSTANT, 'system'], + last_checkin: lastCheckIn, + }, + references: [], + updated_at: lastCheckIn, + version: 'WzI4MSwxXQ==', + score: 0, + }, + ], +}); + +/** + * + * @param running - allows us to set whether the mocked endpoint is in an active or disabled/failed state + * @param updatedDate - the last time the endpoint was updated. Defaults to current ISO time. + * @description We request the events triggered by the agent and get the most recent endpoint event to confirm it is still running. This allows us to mock both scenarios + */ +export const mockFleetEventsObjectsResponse = ( + running?: boolean, + updatedDate = new Date().toISOString() +): SavedObjectsFindResponse => { + return { + page: 1, + per_page: 20, + total: 2, + saved_objects: [ + { + type: AGENT_EVENT_SAVED_OBJECT_TYPE, + id: 'id1', + attributes: { + agent_id: testAgentId, + type: running ? 'STATE' : 'ERROR', + timestamp: updatedDate, + subtype: running ? 'RUNNING' : 'FAILED', + message: `Application: endpoint-security--8.0.0[d8f7f6e8-9375-483c-b456-b479f1d7a4f2]: State changed to ${ + running ? 'RUNNING' : 'FAILED' + }: `, + config_id: testConfigId, + }, + references: [], + updated_at: updatedDate, + version: 'WzExOCwxXQ==', + score: 0, + }, + { + type: AGENT_EVENT_SAVED_OBJECT_TYPE, + id: 'id2', + attributes: { + agent_id: testAgentId, + type: 'STATE', + timestamp: updatedDate, + subtype: 'STARTING', + message: + 'Application: endpoint-security--8.0.0[d8f7f6e8-9375-483c-b456-b479f1d7a4f2]: State changed to STARTING: Starting', + config_id: testConfigId, + }, + references: [], + updated_at: updatedDate, + version: 'WzExNywxXQ==', + score: 0, + }, + ], + }; +}; diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts new file mode 100644 index 00000000000000..0b2f4e4ed9dbec --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/endpoints/endpoint.test.ts @@ -0,0 +1,116 @@ +/* + * 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 { savedObjectsRepositoryMock } from 'src/core/server/mocks'; +import { + mockFleetObjectsResponse, + mockFleetEventsObjectsResponse, + MockOSFullName, + MockOSPlatform, + MockOSVersion, +} from './endpoint.mocks'; +import { ISavedObjectsRepository, SavedObjectsFindResponse } from 'src/core/server'; +import { AgentEventSOAttributes } from '../../../../ingest_manager/common/types/models/agent'; +import { Agent } from '../../../../ingest_manager/common'; +import * as endpointTelemetry from './index'; +import * as fleetSavedObjects from './fleet_saved_objects'; + +describe('test security solution endpoint telemetry', () => { + let mockSavedObjectsRepository: jest.Mocked; + let getFleetSavedObjectsMetadataSpy: jest.SpyInstance>>; + let getFleetEventsSavedObjectsSpy: jest.SpyInstance + >>; + + beforeAll(() => { + getFleetEventsSavedObjectsSpy = jest.spyOn(fleetSavedObjects, 'getFleetEventsSavedObjects'); + getFleetSavedObjectsMetadataSpy = jest.spyOn(fleetSavedObjects, 'getFleetSavedObjectsMetadata'); + mockSavedObjectsRepository = savedObjectsRepositoryMock.create(); + }); + + afterAll(() => { + jest.resetAllMocks(); + }); + + it('should have a default shape', () => { + expect(endpointTelemetry.getDefaultEndpointTelemetry()).toMatchInlineSnapshot(` + Object { + "active_within_last_24_hours": 0, + "os": Array [], + "total_installed": 0, + } + `); + }); + + describe('when an agent has not been installed', () => { + it('should return the default shape if no agents are found', async () => { + getFleetSavedObjectsMetadataSpy.mockImplementation(() => + Promise.resolve({ saved_objects: [], total: 0, per_page: 0, page: 0 }) + ); + + const emptyEndpointTelemetryData = await endpointTelemetry.getEndpointTelemetryFromFleet( + mockSavedObjectsRepository + ); + expect(getFleetSavedObjectsMetadataSpy).toHaveBeenCalled(); + expect(emptyEndpointTelemetryData).toEqual({ + total_installed: 0, + active_within_last_24_hours: 0, + os: [], + }); + }); + }); + + describe('when an agent has been installed', () => { + it('should show one enpoint installed but it is inactive', async () => { + getFleetSavedObjectsMetadataSpy.mockImplementation(() => + Promise.resolve(mockFleetObjectsResponse()) + ); + getFleetEventsSavedObjectsSpy.mockImplementation(() => + Promise.resolve(mockFleetEventsObjectsResponse()) + ); + + const emptyEndpointTelemetryData = await endpointTelemetry.getEndpointTelemetryFromFleet( + mockSavedObjectsRepository + ); + expect(emptyEndpointTelemetryData).toEqual({ + total_installed: 1, + active_within_last_24_hours: 0, + os: [ + { + full_name: MockOSFullName, + platform: MockOSPlatform, + version: MockOSVersion, + count: 1, + }, + ], + }); + }); + + it('should show one endpoint installed and it is active', async () => { + getFleetSavedObjectsMetadataSpy.mockImplementation(() => + Promise.resolve(mockFleetObjectsResponse()) + ); + getFleetEventsSavedObjectsSpy.mockImplementation(() => + Promise.resolve(mockFleetEventsObjectsResponse(true)) + ); + + const emptyEndpointTelemetryData = await endpointTelemetry.getEndpointTelemetryFromFleet( + mockSavedObjectsRepository + ); + expect(emptyEndpointTelemetryData).toEqual({ + total_installed: 1, + active_within_last_24_hours: 1, + os: [ + { + full_name: MockOSFullName, + platform: MockOSPlatform, + version: MockOSVersion, + count: 1, + }, + ], + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts b/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts new file mode 100644 index 00000000000000..70657ed9f08f7b --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/endpoints/fleet_saved_objects.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ISavedObjectsRepository } from 'src/core/server'; +import { AgentEventSOAttributes } from './../../../../ingest_manager/common/types/models/agent'; +import { + AGENT_SAVED_OBJECT_TYPE, + AGENT_EVENT_SAVED_OBJECT_TYPE, +} from './../../../../ingest_manager/common/constants/agent'; +import { Agent, DefaultPackages as FleetDefaultPackages } from '../../../../ingest_manager/common'; + +export const FLEET_ENDPOINT_PACKAGE_CONSTANT = FleetDefaultPackages.endpoint; + +export const getFleetSavedObjectsMetadata = async (savedObjectsClient: ISavedObjectsRepository) => + savedObjectsClient.find({ + type: AGENT_SAVED_OBJECT_TYPE, + fields: ['packages', 'last_checkin', 'local_metadata'], + filter: `${AGENT_SAVED_OBJECT_TYPE}.attributes.packages: ${FLEET_ENDPOINT_PACKAGE_CONSTANT}`, + sortField: 'enrolled_at', + sortOrder: 'desc', + }); + +export const getFleetEventsSavedObjects = async ( + savedObjectsClient: ISavedObjectsRepository, + agentId: string +) => + savedObjectsClient.find({ + type: AGENT_EVENT_SAVED_OBJECT_TYPE, + filter: `${AGENT_EVENT_SAVED_OBJECT_TYPE}.attributes.agent_id: ${agentId} and ${AGENT_EVENT_SAVED_OBJECT_TYPE}.attributes.message: "${FLEET_ENDPOINT_PACKAGE_CONSTANT}"`, + sortField: 'timestamp', + sortOrder: 'desc', + search: agentId, + searchFields: ['agent_id'], + }); diff --git a/x-pack/plugins/security_solution/server/usage/endpoints/index.ts b/x-pack/plugins/security_solution/server/usage/endpoints/index.ts new file mode 100644 index 00000000000000..576d248613d1e1 --- /dev/null +++ b/x-pack/plugins/security_solution/server/usage/endpoints/index.ts @@ -0,0 +1,159 @@ +/* + * 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 { ISavedObjectsRepository } from 'src/core/server'; +import { AgentMetadata } from '../../../../ingest_manager/common/types/models/agent'; +import { + getFleetSavedObjectsMetadata, + getFleetEventsSavedObjects, + FLEET_ENDPOINT_PACKAGE_CONSTANT, +} from './fleet_saved_objects'; + +export interface AgentOSMetadataTelemetry { + full_name: string; + platform: string; + version: string; + count: number; +} + +export interface PoliciesTelemetry { + malware: { + success: number; + warning: number; + failure: number; + }; +} + +export interface EndpointUsage { + total_installed: number; + active_within_last_24_hours: number; + os: AgentOSMetadataTelemetry[]; + policies?: PoliciesTelemetry; // TODO: make required when able to enable policy information +} + +export interface AgentLocalMetadata extends AgentMetadata { + elastic: { + agent: { + id: string; + }; + }; + host: { + id: string; + }; + os: { + name: string; + platform: string; + version: string; + full: string; + }; +} + +export type OSTracker = Record; +/** + * @description returns an empty telemetry object to be incrmented and updated within the `getEndpointTelemetryFromFleet` fn + */ +export const getDefaultEndpointTelemetry = (): EndpointUsage => ({ + total_installed: 0, + active_within_last_24_hours: 0, + os: [], +}); + +export const trackEndpointOSTelemetry = ( + os: AgentLocalMetadata['os'], + osTracker: OSTracker +): OSTracker => { + const updatedOSTracker = { ...osTracker }; + const { version: osVersion, platform: osPlatform, full: osFullName } = os; + if (osFullName && osVersion) { + if (updatedOSTracker[osFullName]) updatedOSTracker[osFullName].count += 1; + else { + updatedOSTracker[osFullName] = { + full_name: osFullName, + platform: osPlatform, + version: osVersion, + count: 1, + }; + } + } + + return updatedOSTracker; +}; + +/** + * @description This aggregates the telemetry details from the two fleet savedObject sources, `fleet-agents` and `fleet-agent-events` to populate + * the telemetry details for endpoint. Since we cannot access our own indices due to `kibana_system` not having access, this is the best alternative. + * Once the data is requested, we iterate over all agents with endpoints registered, and then request the events for each active agent (within last 24 hours) + * to confirm whether or not the endpoint is still active + */ +export const getEndpointTelemetryFromFleet = async ( + savedObjectsClient: ISavedObjectsRepository +): Promise => { + // Retrieve every agent that references the endpoint as an installed package. It will not be listed if it was never installed + const { saved_objects: endpointAgents } = await getFleetSavedObjectsMetadata(savedObjectsClient); + const endpointTelemetry = getDefaultEndpointTelemetry(); + + // If there are no installed endpoints return the default telemetry object + if (!endpointAgents || endpointAgents.length < 1) return endpointTelemetry; + + // Use unique hosts to prevent any potential duplicates + const uniqueHostIds: Set = new Set(); + // Need unique agents to get events data for those that have run in last 24 hours + const uniqueAgentIds: Set = new Set(); + + const aDayAgo = new Date(); + aDayAgo.setDate(aDayAgo.getDate() - 1); + let osTracker: OSTracker = {}; + + const endpointMetadataTelemetry = endpointAgents.reduce( + (metadataTelemetry, { attributes: metadataAttributes }) => { + const { last_checkin: lastCheckin, local_metadata: localMetadata } = metadataAttributes; + // The extended AgentMetadata is just an empty blob, so cast to account for our specific use case + const { host, os, elastic } = localMetadata as AgentLocalMetadata; + + if (lastCheckin && new Date(lastCheckin) > aDayAgo) { + // Get agents that have checked in within the last 24 hours to later see if their endpoints are running + uniqueAgentIds.add(elastic.agent.id); + } + if (host && uniqueHostIds.has(host.id)) { + return metadataTelemetry; + } else { + uniqueHostIds.add(host.id); + osTracker = trackEndpointOSTelemetry(os, osTracker); + return metadataTelemetry; + } + }, + endpointTelemetry + ); + + // All unique agents with an endpoint installed. You can technically install a new agent on a host, so relying on most recently installed. + endpointTelemetry.total_installed = uniqueHostIds.size; + + // Get the objects to populate our OS Telemetry + endpointMetadataTelemetry.os = Object.values(osTracker); + + // Check for agents running in the last 24 hours whose endpoints are still active + for (const agentId of uniqueAgentIds) { + const { saved_objects: agentEvents } = await getFleetEventsSavedObjects( + savedObjectsClient, + agentId + ); + const lastEndpointStatus = agentEvents.find((agentEvent) => + agentEvent.attributes.message.includes(FLEET_ENDPOINT_PACKAGE_CONSTANT) + ); + + /* + We can assume that if the last status of the endpoint is RUNNING and the agent has checked in within the last 24 hours + then the endpoint has still been running within the last 24 hours. If / when we get the policy response, then we can use that + instead + */ + const endpointIsActive = lastEndpointStatus?.attributes.subtype === 'RUNNING'; + if (endpointIsActive) { + endpointMetadataTelemetry.active_within_last_24_hours += 1; + } + } + + return endpointMetadataTelemetry; +}; diff --git a/x-pack/plugins/security_solution/server/usage/types.ts b/x-pack/plugins/security_solution/server/usage/types.ts index 955a4eaf4be5af..9f8ebf80b65b5f 100644 --- a/x-pack/plugins/security_solution/server/usage/types.ts +++ b/x-pack/plugins/security_solution/server/usage/types.ts @@ -4,9 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ +import { CoreSetup } from 'src/core/server'; import { SetupPlugins } from '../plugin'; -export type CollectorDependencies = { kibanaIndex: string } & Pick< +export type CollectorDependencies = { kibanaIndex: string; core: CoreSetup } & Pick< SetupPlugins, 'ml' | 'usageCollection' >; diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index c5d528cbcce232..a7bc29f9efae2d 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -217,6 +217,49 @@ } } } + }, + "endpoints": { + "properties": { + "total_installed": { + "type": "long" + }, + "active_within_last_24_hours": { + "type": "long" + }, + "os": { + "properties": { + "full_name": { + "type": "keyword" + }, + "platform": { + "type": "keyword" + }, + "version": { + "type": "keyword" + }, + "count": { + "type": "long" + } + } + }, + "policies": { + "properties": { + "malware": { + "properties": { + "success": { + "type": "long" + }, + "warning": { + "type": "long" + }, + "failure": { + "type": "long" + } + } + } + } + } + } } } }, From 473806c3c818b15f7ff97004218b1873beb99c7e Mon Sep 17 00:00:00 2001 From: Frank Hassanabad Date: Mon, 13 Jul 2020 19:07:35 -0600 Subject: [PATCH 02/57] [SIEM][Detection Engine][Lists] Adds the ability for exception lists to be multi-list queried. (#71540) ## Summary * Adds the ability for exception lists to be multi-list queried * Fixes a bunch of script issues where I did not update everywhere I needed to use `ip_list` and deletes an old list that now lives within the new/lists folder * Fixes a few io-ts issues with Encode Decode while I was in there. * Adds two more types and their tests for supporting converting between comma separated strings and arrays for GET calls. * Fixes one weird circular dep issue while adding more types. You now send into the find an optional comma separated list of exception lists their namespace type and any filters like so: ```ts GET /api/exception_lists/items/_find?list_id=simple_list,endpoint_list&namespace_type=single,agnostic&filtering=filter1,filter2" ``` And this will return the results of both together with each filter applied to each list. If you use a sort field and ordering it will order across the lists together as if they are one list. Filter is optional like before. If you provide less filters than there are lists, the lists will only apply the filters to each list until it runs out of filters and then not filter the other lists. If at least one list is found this will _not_ return a 404 but it will _only_ query the list(s) it did find. If none of the lists are found, then this will return a 404 not found exception. **Script testing** See these files for more information: * find_exception_list_items.sh * find_exception_list_items_by_filter.sh But basically you can create two lists and an item for each of the lists: ```ts ./post_exception_list.sh ./exception_lists/new/exception_list.json ./post_exception_list_item.sh ./exception_lists/new/exception_list_item.json ./post_exception_list.sh ./exception_lists/new/exception_list_agnostic.json ./post_exception_list_item.sh ./exception_lists/new/exception_list_item_agnostic.json ``` And then you can query these two lists together: ```ts ./find_exception_list_items.sh simple_list,endpoint_list single,agnostic ``` Or for filtering you can query both and add a filter for each one: ```ts ./find_exception_list_items_by_filter.sh simple_list,endpoint_list "exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List,exception-list-agnostic.attributes.name:%20Sample%20Endpoint%20Exception%20List" single,agnostic ``` ### Checklist Delete any items that are not applicable to this PR. - [x] [Unit or functional tests](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md#cross-browser-compatibility) were updated or added to match the most common scenarios --- x-pack/plugins/lists/README.md | 8 +- .../lists/common/schemas/common/schemas.ts | 1 - .../create_exception_list_item_schema.ts | 8 +- .../request/create_exception_list_schema.ts | 2 +- .../delete_exception_list_item_schema.ts | 3 +- .../request/delete_exception_list_schema.ts | 3 +- .../find_exception_list_item_schema.ts | 30 +++--- .../request/find_exception_list_schema.ts | 3 +- .../read_exception_list_item_schema.ts | 3 +- .../request/read_exception_list_schema.ts | 3 +- .../update_exception_list_item_schema.ts | 2 +- .../request/update_exception_list_schema.ts | 2 +- .../common/schemas/types/default_namespace.ts | 13 +-- .../types/default_namespace_array.test.ts | 99 +++++++++++++++++++ .../schemas/types/default_namespace_array.ts | 45 +++++++++ .../schemas/types/empty_string_array.test.ts | 79 +++++++++++++++ .../schemas/types/empty_string_array.ts | 45 +++++++++ .../types/non_empty_string_array.test.ts | 94 ++++++++++++++++++ .../schemas/types/non_empty_string_array.ts | 41 ++++++++ .../routes/find_exception_list_item_route.ts | 42 ++++---- .../scripts/delete_all_exception_lists.sh | 2 +- .../exception_lists/new/exception_list.json | 4 +- .../new/exception_list_item.json | 4 +- .../new/exception_list_item_with_list.json | 2 +- .../scripts/export_list_items_to_file.sh | 2 +- .../scripts/find_exception_list_items.sh | 19 +++- .../find_exception_list_items_by_filter.sh | 24 +++-- .../lists/server/scripts/find_list_items.sh | 4 +- .../scripts/find_list_items_with_cursor.sh | 4 +- .../scripts/find_list_items_with_sort.sh | 4 +- .../find_list_items_with_sort_cursor.sh | 4 +- .../lists/server/scripts/import_list_items.sh | 4 +- .../scripts/lists/new/list_ip_item.json | 5 - .../create_exception_list_item.ts | 2 +- .../exception_lists/exception_list_client.ts | 24 +++++ .../exception_list_client_types.ts | 13 +++ .../find_exception_list_item.ts | 50 ++-------- .../find_exception_list_items.test.ts | 94 ++++++++++++++++++ .../find_exception_list_items.ts | 94 ++++++++++++++++++ .../get_exception_list_item.ts | 3 +- .../server/services/exception_lists/index.ts | 10 +- .../server/services/exception_lists/utils.ts | 31 ++++-- 42 files changed, 786 insertions(+), 143 deletions(-) create mode 100644 x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/empty_string_array.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts create mode 100644 x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts delete mode 100644 x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json create mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts create mode 100644 x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts diff --git a/x-pack/plugins/lists/README.md b/x-pack/plugins/lists/README.md index b6061368f6b13e..dac6e8bb78fa57 100644 --- a/x-pack/plugins/lists/README.md +++ b/x-pack/plugins/lists/README.md @@ -57,7 +57,7 @@ which will: - Delete any existing exception list items you have - Delete any existing mapping, policies, and templates, you might have previously had. - Add the latest list and list item index and its mappings using your settings from `kibana.dev.yml` environment variable of `xpack.lists.listIndex` and `xpack.lists.listItemIndex`. -- Posts the sample list from `./lists/new/list_ip.json` +- Posts the sample list from `./lists/new/ip_list.json` Now you can run @@ -69,7 +69,7 @@ You should see the new list created like so: ```sh { - "id": "list_ip", + "id": "ip_list", "created_at": "2020-05-28T19:15:22.344Z", "created_by": "yo", "description": "This list describes bad internet ip", @@ -96,7 +96,7 @@ You should see the new list item created and attached to the above list like so: "value": "127.0.0.1", "created_at": "2020-05-28T19:15:49.790Z", "created_by": "yo", - "list_id": "list_ip", + "list_id": "ip_list", "tie_breaker_id": "a881bf2e-1e17-4592-bba8-d567cb07d234", "updated_at": "2020-05-28T19:15:49.790Z", "updated_by": "yo" @@ -195,7 +195,7 @@ You can then do find for each one like so: "cursor": "WzIwLFsiYzU3ZWZiYzQtNDk3Ny00YTMyLTk5NWYtY2ZkMjk2YmVkNTIxIl1d", "data": [ { - "id": "list_ip", + "id": "ip_list", "created_at": "2020-05-28T19:15:22.344Z", "created_by": "yo", "description": "This list describes bad internet ip", diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index 6bb6ee05034cb7..6199a5f16f1094 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -273,7 +273,6 @@ export const cursorOrUndefined = t.union([cursor, t.undefined]); export type CursorOrUndefined = t.TypeOf; export const namespace_type = DefaultNamespace; -export type NamespaceType = t.TypeOf; export const operator = t.keyof({ excluded: null, included: null }); export type Operator = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts index fb452ac89576d9..4b7db3eee35bc2 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts @@ -10,7 +10,6 @@ import * as t from 'io-ts'; import { ItemId, - NamespaceType, Tags, _Tags, _tags, @@ -23,7 +22,12 @@ import { tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; -import { CreateCommentsArray, DefaultCreateCommentsArray, DefaultEntryArray } from '../types'; +import { + CreateCommentsArray, + DefaultCreateCommentsArray, + DefaultEntryArray, + NamespaceType, +} from '../types'; import { EntriesArray } from '../types/entries'; import { DefaultUuid } from '../../siem_common_deps'; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts index a0aaa91c81427d..66cca4ab9ca531 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts @@ -10,7 +10,6 @@ import * as t from 'io-ts'; import { ListId, - NamespaceType, Tags, _Tags, _tags, @@ -23,6 +22,7 @@ import { } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; import { DefaultUuid } from '../../siem_common_deps'; +import { NamespaceType } from '../types'; export const createExceptionListSchema = t.intersection([ t.exact( diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts index 4c5b70d9a40738..909960c9fffc03 100644 --- a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_item_schema.ts @@ -8,7 +8,8 @@ import * as t from 'io-ts'; -import { NamespaceType, id, item_id, namespace_type } from '../common/schemas'; +import { id, item_id, namespace_type } from '../common/schemas'; +import { NamespaceType } from '../types'; export const deleteExceptionListItemSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts index 2577d867031f07..3bf5e7a4d07824 100644 --- a/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/delete_exception_list_schema.ts @@ -8,7 +8,8 @@ import * as t from 'io-ts'; -import { NamespaceType, id, list_id, namespace_type } from '../common/schemas'; +import { id, list_id, namespace_type } from '../common/schemas'; +import { NamespaceType } from '../types'; export const deleteExceptionListSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts index 31eb4925eb6d65..826da972fe7a37 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_item_schema.ts @@ -8,27 +8,26 @@ import * as t from 'io-ts'; -import { - NamespaceType, - filter, - list_id, - namespace_type, - sort_field, - sort_order, -} from '../common/schemas'; +import { sort_field, sort_order } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; import { StringToPositiveNumber } from '../types/string_to_positive_number'; +import { + DefaultNamespaceArray, + DefaultNamespaceArrayTypeDecoded, +} from '../types/default_namespace_array'; +import { NonEmptyStringArray } from '../types/non_empty_string_array'; +import { EmptyStringArray, EmptyStringArrayDecoded } from '../types/empty_string_array'; export const findExceptionListItemSchema = t.intersection([ t.exact( t.type({ - list_id, + list_id: NonEmptyStringArray, }) ), t.exact( t.partial({ - filter, // defaults to undefined if not set during decode - namespace_type, // defaults to 'single' if not set during decode + filter: EmptyStringArray, // defaults to undefined if not set during decode + namespace_type: DefaultNamespaceArray, // defaults to ['single'] if not set during decode page: StringToPositiveNumber, // defaults to undefined if not set during decode per_page: StringToPositiveNumber, // defaults to undefined if not set during decode sort_field, // defaults to undefined if not set during decode @@ -37,14 +36,15 @@ export const findExceptionListItemSchema = t.intersection([ ), ]); -export type FindExceptionListItemSchemaPartial = t.TypeOf; +export type FindExceptionListItemSchemaPartial = t.OutputOf; // This type is used after a decode since some things are defaults after a decode. export type FindExceptionListItemSchemaPartialDecoded = Omit< - FindExceptionListItemSchemaPartial, - 'namespace_type' + t.TypeOf, + 'namespace_type' | 'filter' > & { - namespace_type: NamespaceType; + filter: EmptyStringArrayDecoded; + namespace_type: DefaultNamespaceArrayTypeDecoded; }; // This type is used after a decode since some things are defaults after a decode. diff --git a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts index fa00c5b0dafb1f..8b9b08ed387b1e 100644 --- a/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/find_exception_list_schema.ts @@ -8,9 +8,10 @@ import * as t from 'io-ts'; -import { NamespaceType, filter, namespace_type, sort_field, sort_order } from '../common/schemas'; +import { filter, namespace_type, sort_field, sort_order } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; import { StringToPositiveNumber } from '../types/string_to_positive_number'; +import { NamespaceType } from '../types'; export const findExceptionListSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts index 93a372ba383b0a..d8864a6fc66e5e 100644 --- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_item_schema.ts @@ -8,8 +8,9 @@ import * as t from 'io-ts'; -import { NamespaceType, id, item_id, namespace_type } from '../common/schemas'; +import { id, item_id, namespace_type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; +import { NamespaceType } from '../types'; export const readExceptionListItemSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts index 3947c88bf4c9ce..613fb22a99d618 100644 --- a/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/read_exception_list_schema.ts @@ -8,8 +8,9 @@ import * as t from 'io-ts'; -import { NamespaceType, id, list_id, namespace_type } from '../common/schemas'; +import { id, list_id, namespace_type } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; +import { NamespaceType } from '../types'; export const readExceptionListSchema = t.exact( t.partial({ diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts index 582fabdc160f9e..20a63e0fc7dac5 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts @@ -9,7 +9,6 @@ import * as t from 'io-ts'; import { - NamespaceType, Tags, _Tags, _tags, @@ -26,6 +25,7 @@ import { DefaultEntryArray, DefaultUpdateCommentsArray, EntriesArray, + NamespaceType, UpdateCommentsArray, } from '../types'; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts index 76160c3419449a..0b5f3a8a017942 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts @@ -9,7 +9,6 @@ import * as t from 'io-ts'; import { - NamespaceType, Tags, _Tags, _tags, @@ -21,6 +20,7 @@ import { tags, } from '../common/schemas'; import { Identity, RequiredKeepUndefined } from '../../types'; +import { NamespaceType } from '../types'; export const updateExceptionListSchema = t.intersection([ t.exact( diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts index 8f8f8d105b6241..ecc45d3c843131 100644 --- a/x-pack/plugins/lists/common/schemas/types/default_namespace.ts +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace.ts @@ -8,23 +8,18 @@ import * as t from 'io-ts'; import { Either } from 'fp-ts/lib/Either'; export const namespaceType = t.keyof({ agnostic: null, single: null }); - -type NamespaceType = t.TypeOf; - -export type DefaultNamespaceC = t.Type; +export type NamespaceType = t.TypeOf; /** * Types the DefaultNamespace as: * - If null or undefined, then a default string/enumeration of "single" will be used. */ -export const DefaultNamespace: DefaultNamespaceC = new t.Type< - NamespaceType, - NamespaceType, - unknown ->( +export const DefaultNamespace = new t.Type( 'DefaultNamespace', namespaceType.is, (input, context): Either => input == null ? t.success('single') : namespaceType.validate(input, context), t.identity ); + +export type DefaultNamespaceC = typeof DefaultNamespace; diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts new file mode 100644 index 00000000000000..055f93069950e8 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.test.ts @@ -0,0 +1,99 @@ +/* + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { DefaultNamespaceArray, DefaultNamespaceArrayTypeEncoded } from './default_namespace_array'; + +describe('default_namespace_array', () => { + test('it should validate "null" single item as an array with a "single" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = null; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single']); + }); + + test('it should NOT validate a numeric value', () => { + const payload = 5; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "DefaultNamespaceArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate "undefined" item as an array with a "single" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = undefined; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single']); + }); + + test('it should validate "single" as an array of a "single" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'single'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([payload]); + }); + + test('it should validate "agnostic" as an array of a "agnostic" value', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'agnostic'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([payload]); + }); + + test('it should validate "single,agnostic" as an array of 2 values of ["single", "agnostic"] values', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'agnostic,single'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['agnostic', 'single']); + }); + + test('it should validate 3 elements of "single,agnostic,single" as an array of 3 values of ["single", "agnostic", "single"] values', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'single,agnostic,single'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single', 'agnostic', 'single']); + }); + + test('it should validate 3 elements of "single,agnostic, single" as an array of 3 values of ["single", "agnostic", "single"] values when there are spaces', () => { + const payload: DefaultNamespaceArrayTypeEncoded = ' single, agnostic, single '; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['single', 'agnostic', 'single']); + }); + + test('it should not validate 3 elements of "single,agnostic,junk" since the 3rd value is junk', () => { + const payload: DefaultNamespaceArrayTypeEncoded = 'single,agnostic,junk'; + const decoded = DefaultNamespaceArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "junk" supplied to "DefaultNamespaceArray"', + ]); + expect(message.schema).toEqual({}); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts new file mode 100644 index 00000000000000..c4099a48ffbcc7 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/default_namespace_array.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +import { namespaceType } from './default_namespace'; + +export const namespaceTypeArray = t.array(namespaceType); +export type NamespaceTypeArray = t.TypeOf; + +/** + * Types the DefaultNamespaceArray as: + * - If null or undefined, then a default string array of "single" will be used. + * - If it contains a string, then it is split along the commas and puts them into an array and validates it + */ +export const DefaultNamespaceArray = new t.Type< + NamespaceTypeArray, + string | undefined | null, + unknown +>( + 'DefaultNamespaceArray', + namespaceTypeArray.is, + (input, context): Either => { + if (input == null) { + return t.success(['single']); + } else if (typeof input === 'string') { + const commaSeparatedValues = input + .trim() + .split(',') + .map((value) => value.trim()); + return namespaceTypeArray.validate(commaSeparatedValues, context); + } + return t.failure(input, context); + }, + String +); + +export type DefaultNamespaceC = typeof DefaultNamespaceArray; + +export type DefaultNamespaceArrayTypeEncoded = t.OutputOf; +export type DefaultNamespaceArrayTypeDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts b/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts new file mode 100644 index 00000000000000..b14afab327fb06 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/empty_string_array.test.ts @@ -0,0 +1,79 @@ +/* + * 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 { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { EmptyStringArray, EmptyStringArrayEncoded } from './empty_string_array'; + +describe('empty_string_array', () => { + test('it should validate "null" and create an empty array', () => { + const payload: EmptyStringArrayEncoded = null; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); + + test('it should validate "undefined" and create an empty array', () => { + const payload: EmptyStringArrayEncoded = undefined; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); + + test('it should validate a single value of "a" into an array of size 1 of ["a"]', () => { + const payload: EmptyStringArrayEncoded = 'a'; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a']); + }); + + test('it should validate 2 values of "a,b" into an array of size 2 of ["a", "b"]', () => { + const payload: EmptyStringArrayEncoded = 'a,b'; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b']); + }); + + test('it should validate 3 values of "a,b,c" into an array of size 3 of ["a", "b", "c"]', () => { + const payload: EmptyStringArrayEncoded = 'a,b,c'; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); + + test('it should NOT validate a number', () => { + const payload: number = 5; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "EmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate 3 values of " a, b, c " into an array of size 3 of ["a", "b", "c"] even though they have spaces', () => { + const payload: EmptyStringArrayEncoded = ' a, b, c '; + const decoded = EmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts b/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts new file mode 100644 index 00000000000000..389dc4a410cc90 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/empty_string_array.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +/** + * Types the EmptyStringArray as: + * - A value that can be undefined, or null (which will be turned into an empty array) + * - A comma separated string that can turn into an array by splitting on it + * - Example input converted to output: undefined -> [] + * - Example input converted to output: null -> [] + * - Example input converted to output: "a,b,c" -> ["a", "b", "c"] + */ +export const EmptyStringArray = new t.Type( + 'EmptyStringArray', + t.array(t.string).is, + (input, context): Either => { + if (input == null) { + return t.success([]); + } else if (typeof input === 'string' && input.trim() !== '') { + const arrayValues = input + .trim() + .split(',') + .map((value) => value.trim()); + const emptyValueFound = arrayValues.some((value) => value === ''); + if (emptyValueFound) { + return t.failure(input, context); + } else { + return t.success(arrayValues); + } + } else { + return t.failure(input, context); + } + }, + String +); + +export type EmptyStringArrayC = typeof EmptyStringArray; + +export type EmptyStringArrayEncoded = t.OutputOf; +export type EmptyStringArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts new file mode 100644 index 00000000000000..6124487cdd7fb0 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; + +import { foldLeftRight, getPaths } from '../../siem_common_deps'; + +import { NonEmptyStringArray, NonEmptyStringArrayEncoded } from './non_empty_string_array'; + +describe('non_empty_string_array', () => { + test('it should NOT validate "null"', () => { + const payload: NonEmptyStringArrayEncoded | null = null; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "null" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate "undefined"', () => { + const payload: NonEmptyStringArrayEncoded | undefined = undefined; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "undefined" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should NOT validate a single value of an empty string ""', () => { + const payload: NonEmptyStringArrayEncoded = ''; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate a single value of "a" into an array of size 1 of ["a"]', () => { + const payload: NonEmptyStringArrayEncoded = 'a'; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a']); + }); + + test('it should validate 2 values of "a,b" into an array of size 2 of ["a", "b"]', () => { + const payload: NonEmptyStringArrayEncoded = 'a,b'; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b']); + }); + + test('it should validate 3 values of "a,b,c" into an array of size 3 of ["a", "b", "c"]', () => { + const payload: NonEmptyStringArrayEncoded = 'a,b,c'; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); + + test('it should NOT validate a number', () => { + const payload: number = 5; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "5" supplied to "NonEmptyStringArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should validate 3 values of " a, b, c " into an array of size 3 of ["a", "b", "c"] even though they have spaces', () => { + const payload: NonEmptyStringArrayEncoded = ' a, b, c '; + const decoded = NonEmptyStringArray.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(['a', 'b', 'c']); + }); +}); diff --git a/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts new file mode 100644 index 00000000000000..c4a640e7cdbad9 --- /dev/null +++ b/x-pack/plugins/lists/common/schemas/types/non_empty_string_array.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +/** + * Types the NonEmptyStringArray as: + * - A string that is not empty (which will be turned into an array of size 1) + * - A comma separated string that can turn into an array by splitting on it + * - Example input converted to output: "a,b,c" -> ["a", "b", "c"] + */ +export const NonEmptyStringArray = new t.Type( + 'NonEmptyStringArray', + t.array(t.string).is, + (input, context): Either => { + if (typeof input === 'string' && input.trim() !== '') { + const arrayValues = input + .trim() + .split(',') + .map((value) => value.trim()); + const emptyValueFound = arrayValues.some((value) => value === ''); + if (emptyValueFound) { + return t.failure(input, context); + } else { + return t.success(arrayValues); + } + } else { + return t.failure(input, context); + } + }, + String +); + +export type NonEmptyStringArrayC = typeof NonEmptyStringArray; + +export type NonEmptyStringArrayEncoded = t.OutputOf; +export type NonEmptyStringArrayDecoded = t.TypeOf; diff --git a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts index a6c2a18bb8c8ab..a318d653450c7d 100644 --- a/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/find_exception_list_item_route.ts @@ -44,26 +44,34 @@ export const findExceptionListItemRoute = (router: IRouter): void => { sort_field: sortField, sort_order: sortOrder, } = request.query; - const exceptionListItems = await exceptionLists.findExceptionListItem({ - filter, - listId, - namespaceType, - page, - perPage, - sortField, - sortOrder, - }); - if (exceptionListItems == null) { + + if (listId.length !== namespaceType.length) { return siemResponse.error({ - body: `list id: "${listId}" does not exist`, - statusCode: 404, + body: `list_id and namespace_id need to have the same comma separated number of values. Expected list_id length: ${listId.length} to equal namespace_type length: ${namespaceType.length}`, + statusCode: 400, }); - } - const [validated, errors] = validate(exceptionListItems, foundExceptionListItemSchema); - if (errors != null) { - return siemResponse.error({ body: errors, statusCode: 500 }); } else { - return response.ok({ body: validated ?? {} }); + const exceptionListItems = await exceptionLists.findExceptionListsItem({ + filter, + listId, + namespaceType, + page, + perPage, + sortField, + sortOrder, + }); + if (exceptionListItems == null) { + return siemResponse.error({ + body: `list id: "${listId}" does not exist`, + statusCode: 404, + }); + } + const [validated, errors] = validate(exceptionListItems, foundExceptionListItemSchema); + if (errors != null) { + return siemResponse.error({ body: errors, statusCode: 500 }); + } else { + return response.ok({ body: validated ?? {} }); + } } } catch (err) { const error = transformError(err); diff --git a/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh b/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh index bb431800c56c33..3241bb84119164 100755 --- a/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh +++ b/x-pack/plugins/lists/server/scripts/delete_all_exception_lists.sh @@ -7,7 +7,7 @@ set -e ./check_env_variables.sh -# Example: ./delete_all_alerts.sh +# Example: ./delete_all_exception_lists.sh # https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html curl -s -k \ -H "Content-Type: application/json" \ diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json index 520bc4ddf1e094..19027ac189a47b 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json @@ -1,8 +1,8 @@ { - "list_id": "endpoint_list", + "list_id": "simple_list", "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], - "type": "endpoint", + "type": "detection", "description": "This is a sample endpoint type exception", "name": "Sample Endpoint Exception List" } diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json index 8663be5d649e5d..eede855aab199f 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json @@ -1,6 +1,6 @@ { - "list_id": "endpoint_list", - "item_id": "endpoint_list_item", + "list_id": "simple_list", + "item_id": "simple_list_item", "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "simple", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json index 3d6253fcb58adb..e0d401eff92694 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json @@ -18,7 +18,7 @@ "field": "source.ip", "operator": "excluded", "type": "list", - "list": { "id": "list-ip", "type": "ip" } + "list": { "id": "ip_list", "type": "ip" } } ] } diff --git a/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh b/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh index 5efad01e9a68ec..ba8f1cd0477a12 100755 --- a/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh +++ b/x-pack/plugins/lists/server/scripts/export_list_items_to_file.sh @@ -21,6 +21,6 @@ pushd ${FOLDER} > /dev/null curl -s -k -OJ \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ - -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_export?list_id=list-ip" + -X POST "${KIBANA_URL}${SPACE_URL}/api/lists/items/_export?list_id=ip_list" popd > /dev/null diff --git a/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh b/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh index e3f21da56d1b79..ff720afba4157c 100755 --- a/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh +++ b/x-pack/plugins/lists/server/scripts/find_exception_list_items.sh @@ -9,12 +9,23 @@ set -e ./check_env_variables.sh -LIST_ID=${1:-endpoint_list} +LIST_ID=${1:-simple_list} NAMESPACE_TYPE=${2-single} -# Example: ./find_exception_list_items.sh {list-id} -# Example: ./find_exception_list_items.sh {list-id} single -# Example: ./find_exception_list_items.sh {list-id} agnostic +# First, post two different lists and two list items for the example to work +# ./post_exception_list.sh ./exception_lists/new/exception_list.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item.json +# +# ./post_exception_list.sh ./exception_lists/new/exception_list_agnostic.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item_agnostic.json + +# Querying a single list item aginst each type +# Example: ./find_exception_list_items.sh simple_list +# Example: ./find_exception_list_items.sh simple_list single +# Example: ./find_exception_list_items.sh endpoint_list agnostic +# +# Finding multiple list id's across multiple spaces +# Example: ./find_exception_list_items.sh simple_list,endpoint_list single,agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID}&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh b/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh index 57313275ccd0e6..79e66be42e4415 100755 --- a/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh +++ b/x-pack/plugins/lists/server/scripts/find_exception_list_items_by_filter.sh @@ -9,7 +9,7 @@ set -e ./check_env_variables.sh -LIST_ID=${1:-endpoint_list} +LIST_ID=${1:-simple_list} FILTER=${2:-'exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List'} NAMESPACE_TYPE=${3-single} @@ -17,13 +17,23 @@ NAMESPACE_TYPE=${3-single} # The %22 is just an encoded quote of " # Table of them for testing if needed: https://www.w3schools.com/tags/ref_urlencode.asp -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List single -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List agnostic +# First, post two different lists and two list items for the example to work +# ./post_exception_list.sh ./exception_lists/new/exception_list.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item.json # -# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list.attributes.entries.field:actingProcess.file.signer -# Example: ./find_exception_list_items_by_filter.sh endpoint_list "exception-list.attributes.entries.field:actingProcess.file.signe*" -# Example: ./find_exception_list_items_by_filter.sh endpoint_list "exception-list.attributes.entries.match:Elastic*%20AND%20exception-list.attributes.entries.field:actingProcess.file.signe*" +# ./post_exception_list.sh ./exception_lists/new/exception_list_agnostic.json +# ./post_exception_list_item.sh ./exception_lists/new/exception_list_item_agnostic.json + +# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List +# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List single +# Example: ./find_exception_list_items_by_filter.sh endpoint_list exception-list-agnostic.attributes.name:%20Sample%20Endpoint%20Exception%20List agnostic +# +# Example: ./find_exception_list_items_by_filter.sh simple_list exception-list.attributes.entries.field:actingProcess.file.signer +# Example: ./find_exception_list_items_by_filter.sh simple_list "exception-list.attributes.entries.field:actingProcess.file.signe*" +# Example: ./find_exception_list_items_by_filter.sh simple_list "exception-list.attributes.entries.field:actingProcess.file.signe*%20AND%20exception-list.attributes.entries.field:actingProcess.file.signe*" +# +# Example with multiplie lists, and multiple filters +# Example: ./find_exception_list_items_by_filter.sh simple_list,endpoint_list "exception-list.attributes.name:%20Sample%20Endpoint%20Exception%20List,exception-list-agnostic.attributes.name:%20Sample%20Endpoint%20Exception%20List" single,agnostic curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/exception_lists/items/_find?list_id=${LIST_ID}&filter=${FILTER}&namespace_type=${NAMESPACE_TYPE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items.sh b/x-pack/plugins/lists/server/scripts/find_list_items.sh index 9c8bfd2d5a4906..d475da3db61f10 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items.sh @@ -9,11 +9,11 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} -# Example: ./find_list_items.sh list-ip 1 20 +# Example: ./find_list_items.sh ip_list 1 20 curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh index 8924012cf62cf2..38cef7c98994b9 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_cursor.sh @@ -9,7 +9,7 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} CURSOR=${4-invalid} @@ -17,7 +17,7 @@ CURSOR=${4-invalid} # Example: # ./find_list_items.sh 1 20 | jq .cursor # Copy the cursor into the argument below like so -# ./find_list_items_with_cursor.sh list-ip 1 10 eyJwYWdlX2luZGV4IjoyMCwic2VhcmNoX2FmdGVyIjpbIjAyZDZlNGY3LWUzMzAtNGZkYi1iNTY0LTEzZjNiOTk1MjRiYSJdfQ== +# ./find_list_items_with_cursor.sh ip_list 1 10 eyJwYWdlX2luZGV4IjoyMCwic2VhcmNoX2FmdGVyIjpbIjAyZDZlNGY3LWUzMzAtNGZkYi1iNTY0LTEzZjNiOTk1MjRiYSJdfQ== curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&cursor=${CURSOR}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh index 37d80c3dd3f288..eb4b23236b7d42 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort.sh @@ -9,13 +9,13 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} SORT_FIELD=${4-value} SORT_ORDER=${4-asc} -# Example: ./find_list_items_with_sort.sh list-ip 1 20 value asc +# Example: ./find_list_items_with_sort.sh ip_list 1 20 value asc curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh index 27d8deb2fc95a1..289f9be82f2094 100755 --- a/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh +++ b/x-pack/plugins/lists/server/scripts/find_list_items_with_sort_cursor.sh @@ -9,14 +9,14 @@ set -e ./check_env_variables.sh -LIST_ID=${1-list-ip} +LIST_ID=${1-ip_list} PAGE=${2-1} PER_PAGE=${3-20} SORT_FIELD=${4-value} SORT_ORDER=${5-asc} CURSOR=${6-invalid} -# Example: ./find_list_items_with_sort_cursor.sh list-ip 1 20 value asc +# Example: ./find_list_items_with_sort_cursor.sh ip_list 1 20 value asc curl -s -k \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ -X GET "${KIBANA_URL}${SPACE_URL}/api/lists/items/_find?list_id=${LIST_ID}&page=${PAGE}&per_page=${PER_PAGE}&sort_field=${SORT_FIELD}&sort_order=${SORT_ORDER}&cursor=${CURSOR}" | jq . diff --git a/x-pack/plugins/lists/server/scripts/import_list_items.sh b/x-pack/plugins/lists/server/scripts/import_list_items.sh index a39409cd082677..2ef01fdeed3430 100755 --- a/x-pack/plugins/lists/server/scripts/import_list_items.sh +++ b/x-pack/plugins/lists/server/scripts/import_list_items.sh @@ -10,10 +10,10 @@ set -e ./check_env_variables.sh # Uses a defaults if no argument is specified -LIST_ID=${1:-list-ip} +LIST_ID=${1:-ip_list} FILE=${2:-./lists/files/ips.txt} -# ./import_list_items.sh list-ip ./lists/files/ips.txt +# ./import_list_items.sh ip_list ./lists/files/ips.txt curl -s -k \ -H 'kbn-xsrf: 123' \ -u ${ELASTICSEARCH_USERNAME}:${ELASTICSEARCH_PASSWORD} \ diff --git a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json b/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json deleted file mode 100644 index d150cfaecc2028..00000000000000 --- a/x-pack/plugins/lists/server/scripts/lists/new/list_ip_item.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "id": "hand_inserted_item_id", - "list_id": "list-ip", - "value": "10.4.3.11" -} diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts index a731371a6ffacc..1acc880c851a68 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts @@ -82,5 +82,5 @@ export const createExceptionListItem = async ({ type, updated_by: user, }); - return transformSavedObjectToExceptionListItem({ namespaceType, savedObject }); + return transformSavedObjectToExceptionListItem({ savedObject }); }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 73c52fb8b3ec99..62afda52bd79de 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -21,6 +21,7 @@ import { DeleteExceptionListOptions, FindExceptionListItemOptions, FindExceptionListOptions, + FindExceptionListsItemOptions, GetExceptionListItemOptions, GetExceptionListOptions, UpdateExceptionListItemOptions, @@ -36,6 +37,7 @@ import { deleteExceptionList } from './delete_exception_list'; import { deleteExceptionListItem } from './delete_exception_list_item'; import { findExceptionListItem } from './find_exception_list_item'; import { findExceptionList } from './find_exception_list'; +import { findExceptionListsItem } from './find_exception_list_items'; export class ExceptionListClient { private readonly user: string; @@ -229,6 +231,28 @@ export class ExceptionListClient { }); }; + public findExceptionListsItem = async ({ + listId, + filter, + perPage, + page, + sortField, + sortOrder, + namespaceType, + }: FindExceptionListsItemOptions): Promise => { + const { savedObjectsClient } = this; + return findExceptionListsItem({ + filter, + listId, + namespaceType, + page, + perPage, + savedObjectsClient, + sortField, + sortOrder, + }); + }; + public findExceptionList = async ({ filter, perPage, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 3eff2c7e202e74..b3070f2d4a70d1 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -6,6 +6,9 @@ import { SavedObjectsClientContract } from 'kibana/server'; +import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; +import { NonEmptyStringArrayDecoded } from '../../../common/schemas/types/non_empty_string_array'; +import { EmptyStringArrayDecoded } from '../../../common/schemas/types/empty_string_array'; import { CreateCommentsArray, Description, @@ -127,6 +130,16 @@ export interface FindExceptionListItemOptions { sortOrder: SortOrderOrUndefined; } +export interface FindExceptionListsItemOptions { + listId: NonEmptyStringArrayDecoded; + namespaceType: NamespaceTypeArray; + filter: EmptyStringArrayDecoded; + perPage: PerPageOrUndefined; + page: PageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +} + export interface FindExceptionListOptions { namespaceType: NamespaceType; filter: FilterOrUndefined; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts index 1c3103ad1db7e7..e997ff5f9adf19 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_item.ts @@ -7,7 +7,6 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { - ExceptionListSoSchema, FilterOrUndefined, FoundExceptionListItemSchema, ListId, @@ -17,10 +16,8 @@ import { SortFieldOrUndefined, SortOrderOrUndefined, } from '../../../common/schemas'; -import { SavedObjectType } from '../../saved_objects'; -import { getSavedObjectType, transformSavedObjectsToFoundExceptionListItem } from './utils'; -import { getExceptionList } from './get_exception_list'; +import { findExceptionListsItem } from './find_exception_list_items'; interface FindExceptionListItemOptions { listId: ListId; @@ -43,43 +40,14 @@ export const findExceptionListItem = async ({ sortField, sortOrder, }: FindExceptionListItemOptions): Promise => { - const savedObjectType = getSavedObjectType({ namespaceType }); - const exceptionList = await getExceptionList({ - id: undefined, - listId, - namespaceType, + return findExceptionListsItem({ + filter: filter != null ? [filter] : [], + listId: [listId], + namespaceType: [namespaceType], + page, + perPage, savedObjectsClient, + sortField, + sortOrder, }); - if (exceptionList == null) { - return null; - } else { - const savedObjectsFindResponse = await savedObjectsClient.find({ - filter: getExceptionListItemFilter({ filter, listId, savedObjectType }), - page, - perPage, - sortField, - sortOrder, - type: savedObjectType, - }); - return transformSavedObjectsToFoundExceptionListItem({ - namespaceType, - savedObjectsFindResponse, - }); - } -}; - -export const getExceptionListItemFilter = ({ - filter, - listId, - savedObjectType, -}: { - listId: ListId; - filter: FilterOrUndefined; - savedObjectType: SavedObjectType; -}): string => { - if (filter == null) { - return `${savedObjectType}.attributes.list_type: item AND ${savedObjectType}.attributes.list_id: ${listId}`; - } else { - return `${savedObjectType}.attributes.list_type: item AND ${savedObjectType}.attributes.list_id: ${listId} AND ${filter}`; - } }; diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts new file mode 100644 index 00000000000000..a2fbb391037693 --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LIST_ID } from '../../../common/constants.mock'; + +import { getExceptionListsItemFilter } from './find_exception_list_items'; + +describe('find_exception_list_items', () => { + describe('getExceptionListsItemFilter', () => { + test('It should create a filter with a single listId with an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: [LIST_ID], + savedObjectType: ['exception-list'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: some-list-id)' + ); + }); + + test('It should create a filter with a single listId with a single filter', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: [LIST_ID], + savedObjectType: ['exception-list'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: some-list-id) AND exception-list.attributes.name: "Sample Endpoint Exception List")' + ); + }); + + test('It should create a filter with 2 listIds and an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: ['list-1', 'list-2'], + savedObjectType: ['exception-list', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2)' + ); + }); + + test('It should create a filter with 2 listIds and a single filter', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: ['list-1', 'list-2'], + savedObjectType: ['exception-list', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2)' + ); + }); + + test('It should create a filter with 3 listIds and an empty filter', () => { + const filter = getExceptionListsItemFilter({ + filter: [], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '(exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3)' + ); + }); + + test('It should create a filter with 3 listIds and a single filter for the first item', () => { + const filter = getExceptionListsItemFilter({ + filter: ['exception-list.attributes.name: "Sample Endpoint Exception List"'], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List") OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) OR (exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3)' + ); + }); + + test('It should create a filter with 3 listIds and 3 filters for each', () => { + const filter = getExceptionListsItemFilter({ + filter: [ + 'exception-list.attributes.name: "Sample Endpoint Exception List 1"', + 'exception-list.attributes.name: "Sample Endpoint Exception List 2"', + 'exception-list.attributes.name: "Sample Endpoint Exception List 3"', + ], + listId: ['list-1', 'list-2', 'list-3'], + savedObjectType: ['exception-list', 'exception-list-agnostic', 'exception-list-agnostic'], + }); + expect(filter).toEqual( + '((exception-list.attributes.list_type: item AND exception-list.attributes.list_id: list-1) AND exception-list.attributes.name: "Sample Endpoint Exception List 1") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-2) AND exception-list.attributes.name: "Sample Endpoint Exception List 2") OR ((exception-list-agnostic.attributes.list_type: item AND exception-list-agnostic.attributes.list_id: list-3) AND exception-list.attributes.name: "Sample Endpoint Exception List 3")' + ); + }); + }); +}); diff --git a/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts new file mode 100644 index 00000000000000..47a0d809cce67d --- /dev/null +++ b/x-pack/plugins/lists/server/services/exception_lists/find_exception_list_items.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { SavedObjectsClientContract } from 'kibana/server'; + +import { EmptyStringArrayDecoded } from '../../../common/schemas/types/empty_string_array'; +import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; +import { NonEmptyStringArrayDecoded } from '../../../common/schemas/types/non_empty_string_array'; +import { + ExceptionListSoSchema, + FoundExceptionListItemSchema, + PageOrUndefined, + PerPageOrUndefined, + SortFieldOrUndefined, + SortOrderOrUndefined, +} from '../../../common/schemas'; +import { SavedObjectType } from '../../saved_objects'; + +import { getSavedObjectTypes, transformSavedObjectsToFoundExceptionListItem } from './utils'; +import { getExceptionList } from './get_exception_list'; + +interface FindExceptionListItemsOptions { + listId: NonEmptyStringArrayDecoded; + namespaceType: NamespaceTypeArray; + savedObjectsClient: SavedObjectsClientContract; + filter: EmptyStringArrayDecoded; + perPage: PerPageOrUndefined; + page: PageOrUndefined; + sortField: SortFieldOrUndefined; + sortOrder: SortOrderOrUndefined; +} + +export const findExceptionListsItem = async ({ + listId, + namespaceType, + savedObjectsClient, + filter, + page, + perPage, + sortField, + sortOrder, +}: FindExceptionListItemsOptions): Promise => { + const savedObjectType = getSavedObjectTypes({ namespaceType }); + const exceptionLists = ( + await Promise.all( + listId.map((singleListId, index) => { + return getExceptionList({ + id: undefined, + listId: singleListId, + namespaceType: namespaceType[index], + savedObjectsClient, + }); + }) + ) + ).filter((list) => list != null); + if (exceptionLists.length === 0) { + return null; + } else { + const savedObjectsFindResponse = await savedObjectsClient.find({ + filter: getExceptionListsItemFilter({ filter, listId, savedObjectType }), + page, + perPage, + sortField, + sortOrder, + type: savedObjectType, + }); + return transformSavedObjectsToFoundExceptionListItem({ + savedObjectsFindResponse, + }); + } +}; + +export const getExceptionListsItemFilter = ({ + filter, + listId, + savedObjectType, +}: { + listId: NonEmptyStringArrayDecoded; + filter: EmptyStringArrayDecoded; + savedObjectType: SavedObjectType[]; +}): string => { + return listId.reduce((accum, singleListId, index) => { + const listItemAppend = `(${savedObjectType[index]}.attributes.list_type: item AND ${savedObjectType[index]}.attributes.list_id: ${singleListId})`; + const listItemAppendWithFilter = + filter[index] != null ? `(${listItemAppend} AND ${filter[index]})` : listItemAppend; + if (accum === '') { + return listItemAppendWithFilter; + } else { + return `${accum} OR ${listItemAppendWithFilter}`; + } + }, ''); +}; diff --git a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts index d7efdc054c48c7..d68863c02148fd 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/get_exception_list_item.ts @@ -35,7 +35,7 @@ export const getExceptionListItem = async ({ if (id != null) { try { const savedObject = await savedObjectsClient.get(savedObjectType, id); - return transformSavedObjectToExceptionListItem({ namespaceType, savedObject }); + return transformSavedObjectToExceptionListItem({ savedObject }); } catch (err) { if (SavedObjectsErrorHelpers.isNotFoundError(err)) { return null; @@ -55,7 +55,6 @@ export const getExceptionListItem = async ({ }); if (savedObject.saved_objects[0] != null) { return transformSavedObjectToExceptionListItem({ - namespaceType, savedObject: savedObject.saved_objects[0], }); } else { diff --git a/x-pack/plugins/lists/server/services/exception_lists/index.ts b/x-pack/plugins/lists/server/services/exception_lists/index.ts index a66f00819605b0..510b2c70c6c94c 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/index.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/index.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './create_exception_list_item'; export * from './create_exception_list'; -export * from './delete_exception_list_item'; +export * from './create_exception_list_item'; export * from './delete_exception_list'; +export * from './delete_exception_list_item'; +export * from './delete_exception_list_items_by_list'; export * from './find_exception_list'; export * from './find_exception_list_item'; -export * from './get_exception_list_item'; +export * from './find_exception_list_items'; export * from './get_exception_list'; -export * from './update_exception_list_item'; +export * from './get_exception_list_item'; export * from './update_exception_list'; +export * from './update_exception_list_item'; diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts index ab54647430b9b9..ad1e1a3439d7c1 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts @@ -6,6 +6,7 @@ import { SavedObject, SavedObjectsFindResponse, SavedObjectsUpdateResponse } from 'kibana/server'; +import { NamespaceTypeArray } from '../../../common/schemas/types/default_namespace_array'; import { ErrorWithStatusCode } from '../../error_with_status_code'; import { Comments, @@ -42,6 +43,28 @@ export const getSavedObjectType = ({ } }; +export const getExceptionListType = ({ + savedObjectType, +}: { + savedObjectType: string; +}): NamespaceType => { + if (savedObjectType === exceptionListAgnosticSavedObjectType) { + return 'agnostic'; + } else { + return 'single'; + } +}; + +export const getSavedObjectTypes = ({ + namespaceType, +}: { + namespaceType: NamespaceTypeArray; +}): SavedObjectType[] => { + return namespaceType.map((singleNamespaceType) => + getSavedObjectType({ namespaceType: singleNamespaceType }) + ); +}; + export const transformSavedObjectToExceptionList = ({ savedObject, namespaceType, @@ -126,10 +149,8 @@ export const transformSavedObjectUpdateToExceptionList = ({ export const transformSavedObjectToExceptionListItem = ({ savedObject, - namespaceType, }: { savedObject: SavedObject; - namespaceType: NamespaceType; }): ExceptionListItemSchema => { const dateNow = new Date().toISOString(); const { @@ -167,7 +188,7 @@ export const transformSavedObjectToExceptionListItem = ({ list_id, meta, name, - namespace_type: namespaceType, + namespace_type: getExceptionListType({ savedObjectType: savedObject.type }), tags, tie_breaker_id, type: exceptionListItemType.is(type) ? type : 'simple', @@ -229,14 +250,12 @@ export const transformSavedObjectUpdateToExceptionListItem = ({ export const transformSavedObjectsToFoundExceptionListItem = ({ savedObjectsFindResponse, - namespaceType, }: { savedObjectsFindResponse: SavedObjectsFindResponse; - namespaceType: NamespaceType; }): FoundExceptionListItemSchema => { return { data: savedObjectsFindResponse.saved_objects.map((savedObject) => - transformSavedObjectToExceptionListItem({ namespaceType, savedObject }) + transformSavedObjectToExceptionListItem({ savedObject }) ), page: savedObjectsFindResponse.page, per_page: savedObjectsFindResponse.per_page, From 56a2437a6c8353a1fb96e5d3ce588735dab96541 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yulia=20=C4=8Cech?= <6585477+yuliacech@users.noreply.github.com> Date: Tue, 14 Jul 2020 03:10:07 +0200 Subject: [PATCH 03/57] [ILM] Fix alignment of the timing field (#71273) --- .../sections/edit_policy/components/min_age_input.js | 4 ++-- .../components/snapshot_policies/snapshot_policies.tsx | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js index cd690c768a3267..d90ad9378efd4d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input.js @@ -179,7 +179,7 @@ export const MinAgeInput = (props) => { return ( - + { /> - + = ({ value, onChan Date: Tue, 14 Jul 2020 02:14:29 +0100 Subject: [PATCH 04/57] [test] Skips test preventing promotion of ES snapshot #71555 --- .../security_and_spaces/tests/create_rules.ts | 3 ++- .../security_and_spaces/tests/create_rules_bulk.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts index c763be1c2c3ec7..73d39b600cf11f 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules.ts @@ -31,7 +31,8 @@ export default ({ getService }: FtrProviderContext) => { const supertest = getService('supertest'); const es = getService('es'); - describe('create_rules', () => { + // Preventing ES promotion: https://github.com/elastic/kibana/issues/71555 + describe.skip('create_rules', () => { describe('validation errors', () => { it('should give an error that the index must exist first if it does not exist before creating a rule', async () => { const { body } = await supertest diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts index 897738d0919f28..52865e43be7504 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts @@ -29,8 +29,7 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - // Preventing ES promotion: https://github.com/elastic/kibana/issues/71555 - describe.skip('create_rules_bulk', () => { + describe('create_rules_bulk', () => { describe('validation errors', () => { it('should give a 200 even if the index does not exist as all bulks return a 200 but have an error of 409 bad request in the body', async () => { const { body } = await supertest From 683fb42df73e5ca92be299f8112d29c0a4037bab Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 14 Jul 2020 02:33:00 +0100 Subject: [PATCH 05/57] [test] Skips test preventing promotion of ES snapshot #71582 --- .../security_and_spaces/tests/alerting/alerts.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts index ab58a205f9d470..dce809f0b7be98 100644 --- a/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts +++ b/x-pack/test/alerting_api_integration/security_and_spaces/tests/alerting/alerts.ts @@ -26,7 +26,8 @@ export default function alertTests({ getService }: FtrProviderContext) { const esTestIndexTool = new ESTestIndexTool(es, retry); const taskManagerUtils = new TaskManagerUtils(es, retry); - describe('alerts', () => { + // Failing ES promotion: https://github.com/elastic/kibana/issues/71582 + describe.skip('alerts', () => { const authorizationIndex = '.kibana-test-authorization'; const objectRemover = new ObjectRemover(supertest); From 835c13dd6abdb39280784ce6dc1f170ae9894533 Mon Sep 17 00:00:00 2001 From: Ryland Herrick Date: Mon, 13 Jul 2020 21:11:08 -0500 Subject: [PATCH 06/57] [SIEM][Detections] Value Lists Management Modal (#67068) * Add Frontend components for Value Lists Management Modal Imports and uses the hooks provided by the lists plugin. Tests coming next. * Update value list components to use newest Lists API * uses useEffect on a task's state instead of promise chaining * handles the fact that API calls can be rejected with strings * uses exportList function instead of hook * Close modal on outside click * Add hook for using a cursor with paged API calls. For e.g. findLists, we can send along a cursor to optimize our query. On the backend, this cursor is used as part of a search_after query. * Better implementation of useCursor * Does not require args for setCursor as they're already passed to the hook * Finds nearest cursor for the same page size Eventually this logic will also include sortField as part of the hash/lookup, but we do not currently use that on the frontend. * Fixes useCursor hook functionality We were previously storing the cursor on the _current_ page, when it's only truly valid for the _next_ page (and beyond). This was causing a few issues, but now that it's fixed everything works great. * Add cursor to lists query This allows us to search_after a previous page's search, if available. * Do not validate response of export This is just a blob, so we have nothing to validate. * Fix double callback post-import After uploading a list, the modal was being shown twice. Declaring the constituent state dependencies separately fixed the issue. * Update ValueListsForm to manually abort import request These hooks no longer care about/expose an abort function. In this one case where we need that functionality, we can do it ourselves relatively simply. * Default modal table to five rows * Update translation keys following plugin rename * Try to fit table contents on a single row Dates were wrapping (and raw), and so were wrapped in a FormattedDate component. However, since this component didn't wrap, we needed to shrink/truncate the uploaded_by field as well as allow the fileName to truncate. * Add helper function to prevent tests from logging errors https://github.com/enzymejs/enzyme/issues/2073 seems to be an ongoing issue, and causes components with useEffect to update after the test is completed. waitForUpdates ensures that updates have completed within an act() before continuing on. * Add jest tests for our form, table, and modal components * Fix translation conflict * Add more waitForUpdates to new overview page tests Each of these logs a console.error without them. * Fix bad merge resolution That resulted in duplicate exports. * Make cursor an optional parameter to findLists This param is an optimization and not required for basic functionality. * Tweaking Table column sizes Makes actions column smaller, leaving more room for everything else. * Fix bug where onSuccess is called upon pagination change Because fetchLists changes when pagination does, and handleUploadSuccess changes with fetchLists, our useEffect in Form was being fired on every pagination change due to its onSuccess changing. The solution in this instance is to remove fetchLists from handleUploadSuccess's dependencies, as we merely want to invoke fetchLists from it, not change our reference. * Fix failing test It looks like this broke because EuiTable's pagination changed from a button to an anchor tag. * Hide page size options on ValueLists modal table These have style issues, and anything above 5 rows causes the modal to scroll, so we're going to disable it for now. * Update error callbacks now that we have Errors We don't display the nice errors in the case of an ApiError right now, but this is better than it was. * Synchronize delete with the subsequent fetch Our start() no longer resolves in a meaningful way, so we instead need to perform the refetch in an effect watching the result of our delete. * Cast our unknown error to an Error useAsync generally does not know how what its tasks are going to be rejected with, hence the unknown. For these API calls we know that it will be an Error, but I don't currently have a way to type that generally. For now, we'll cast it where we use it. * Import lists code from our new, standardized modules Co-authored-by: Elastic Machine --- x-pack/plugins/lists/common/shared_exports.ts | 1 + .../public/common/hooks/use_cursor.test.ts | 118 ++++++++++++ .../lists/public/common/hooks/use_cursor.ts | 43 +++++ x-pack/plugins/lists/public/lists/api.test.ts | 100 +++++----- x-pack/plugins/lists/public/lists/api.ts | 7 +- x-pack/plugins/lists/public/lists/types.ts | 1 + x-pack/plugins/lists/public/shared_exports.ts | 2 + .../common/shared_imports.ts | 1 + .../public/common/lib/kibana/hooks.ts | 13 +- .../public/common/utils/test_utils.ts | 16 ++ .../form.test.tsx | 109 +++++++++++ .../value_lists_management_modal/form.tsx | 172 ++++++++++++++++++ .../value_lists_management_modal/index.tsx | 7 + .../modal.test.tsx | 63 +++++++ .../value_lists_management_modal/modal.tsx | 164 +++++++++++++++++ .../table.test.tsx | 113 ++++++++++++ .../value_lists_management_modal/table.tsx | 103 +++++++++++ .../translations.ts | 138 ++++++++++++++ .../pages/detection_engine/rules/index.tsx | 14 ++ .../detection_engine/rules/translations.ts | 7 + .../public/overview/pages/overview.test.tsx | 32 +++- .../public/shared_imports.ts | 4 + 22 files changed, 1157 insertions(+), 71 deletions(-) create mode 100644 x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts create mode 100644 x-pack/plugins/lists/public/common/hooks/use_cursor.ts create mode 100644 x-pack/plugins/security_solution/public/common/utils/test_utils.ts create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx create mode 100644 x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts index 2ad7e63d38c048..7bb565792969cb 100644 --- a/x-pack/plugins/lists/common/shared_exports.ts +++ b/x-pack/plugins/lists/common/shared_exports.ts @@ -39,4 +39,5 @@ export { entriesList, namespaceType, ExceptionListType, + Type, } from './schemas'; diff --git a/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts b/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts new file mode 100644 index 00000000000000..b8967086ef9565 --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_cursor.test.ts @@ -0,0 +1,118 @@ +/* + * 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 { act, renderHook } from '@testing-library/react-hooks'; + +import { UseCursorProps, useCursor } from './use_cursor'; + +describe('useCursor', () => { + it('returns undefined cursor if no values have been set', () => { + const { result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + expect(result.current[0]).toBeUndefined(); + }); + + it('retrieves a cursor for the next page of a given page size', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + rerender({ pageIndex: 1, pageSize: 1 }); + act(() => { + result.current[1]('new_cursor'); + }); + + expect(result.current[0]).toBeUndefined(); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + }); + + it('returns undefined cursor for an unknown search', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + act(() => { + result.current[1]('new_cursor'); + }); + + rerender({ pageIndex: 1, pageSize: 2 }); + expect(result.current[0]).toBeUndefined(); + }); + + it('remembers cursor through rerenders', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + rerender({ pageIndex: 1, pageSize: 1 }); + act(() => { + result.current[1]('new_cursor'); + }); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + + rerender({ pageIndex: 0, pageSize: 0 }); + expect(result.current[0]).toBeUndefined(); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + }); + + it('remembers multiple cursors', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + rerender({ pageIndex: 1, pageSize: 1 }); + act(() => { + result.current[1]('new_cursor'); + }); + rerender({ pageIndex: 2, pageSize: 2 }); + act(() => { + result.current[1]('another_cursor'); + }); + + rerender({ pageIndex: 2, pageSize: 1 }); + expect(result.current[0]).toEqual('new_cursor'); + + rerender({ pageIndex: 3, pageSize: 2 }); + expect(result.current[0]).toEqual('another_cursor'); + }); + + it('returns the "nearest" cursor for the given page size', () => { + const { rerender, result } = renderHook((props: UseCursorProps) => useCursor(props), { + initialProps: { pageIndex: 0, pageSize: 0 }, + }); + + rerender({ pageIndex: 1, pageSize: 2 }); + act(() => { + result.current[1]('cursor1'); + }); + rerender({ pageIndex: 2, pageSize: 2 }); + act(() => { + result.current[1]('cursor2'); + }); + rerender({ pageIndex: 3, pageSize: 2 }); + act(() => { + result.current[1]('cursor3'); + }); + + rerender({ pageIndex: 2, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor1'); + + rerender({ pageIndex: 3, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor2'); + + rerender({ pageIndex: 4, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor3'); + + rerender({ pageIndex: 6, pageSize: 2 }); + expect(result.current[0]).toEqual('cursor3'); + }); +}); diff --git a/x-pack/plugins/lists/public/common/hooks/use_cursor.ts b/x-pack/plugins/lists/public/common/hooks/use_cursor.ts new file mode 100644 index 00000000000000..2409436ff3137b --- /dev/null +++ b/x-pack/plugins/lists/public/common/hooks/use_cursor.ts @@ -0,0 +1,43 @@ +/* + * 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 { useCallback, useState } from 'react'; + +export interface UseCursorProps { + pageIndex: number; + pageSize: number; +} +type Cursor = string | undefined; +type SetCursor = (cursor: Cursor) => void; +type UseCursor = (props: UseCursorProps) => [Cursor, SetCursor]; + +const hash = (props: UseCursorProps): string => JSON.stringify(props); + +export const useCursor: UseCursor = ({ pageIndex, pageSize }) => { + const [cache, setCache] = useState>({}); + + const setCursor = useCallback( + (cursor) => { + setCache({ + ...cache, + [hash({ pageIndex: pageIndex + 1, pageSize })]: cursor, + }); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [pageIndex, pageSize] + ); + + let cursor: Cursor; + for (let i = pageIndex; i >= 0; i--) { + const currentProps = { pageIndex: i, pageSize }; + cursor = cache[hash(currentProps)]; + if (cursor) { + break; + } + } + + return [cursor, setCursor]; +}; diff --git a/x-pack/plugins/lists/public/lists/api.test.ts b/x-pack/plugins/lists/public/lists/api.test.ts index d54a3ca6549438..d79dc868023995 100644 --- a/x-pack/plugins/lists/public/lists/api.test.ts +++ b/x-pack/plugins/lists/public/lists/api.test.ts @@ -114,6 +114,7 @@ describe('Value Lists API', () => { it('sends pagination as query parameters', async () => { const abortCtrl = new AbortController(); await findLists({ + cursor: 'cursor', http: httpMock, pageIndex: 1, pageSize: 10, @@ -123,14 +124,21 @@ describe('Value Lists API', () => { expect(httpMock.fetch).toHaveBeenCalledWith( '/api/lists/_find', expect.objectContaining({ - query: { page: 1, per_page: 10 }, + query: { + cursor: 'cursor', + page: 1, + per_page: 10, + }, }) ); }); it('rejects with an error if request payload is invalid (and does not make API call)', async () => { const abortCtrl = new AbortController(); - const payload: ApiPayload = { pageIndex: 10, pageSize: 0 }; + const payload: ApiPayload = { + pageIndex: 10, + pageSize: 0, + }; await expect( findLists({ @@ -144,7 +152,10 @@ describe('Value Lists API', () => { it('rejects with an error if response payload is invalid', async () => { const abortCtrl = new AbortController(); - const payload: ApiPayload = { pageIndex: 1, pageSize: 10 }; + const payload: ApiPayload = { + pageIndex: 1, + pageSize: 10, + }; const badResponse = { ...getFoundListSchemaMock(), cursor: undefined }; httpMock.fetch.mockResolvedValue(badResponse); @@ -269,7 +280,7 @@ describe('Value Lists API', () => { describe('exportList', () => { beforeEach(() => { - httpMock.fetch.mockResolvedValue(getListResponseMock()); + httpMock.fetch.mockResolvedValue({}); }); it('POSTs to the export endpoint', async () => { @@ -319,66 +330,49 @@ describe('Value Lists API', () => { ).rejects.toEqual(new Error('Invalid value "23" supplied to "list_id"')); expect(httpMock.fetch).not.toHaveBeenCalled(); }); + }); + + describe('readListIndex', () => { + beforeEach(() => { + httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock()); + }); - it('rejects with an error if response payload is invalid', async () => { + it('GETs the list index', async () => { const abortCtrl = new AbortController(); - const payload: ApiPayload = { - listId: 'list-id', - }; - const badResponse = { ...getListResponseMock(), id: undefined }; - httpMock.fetch.mockResolvedValue(badResponse); + await readListIndex({ + http: httpMock, + signal: abortCtrl.signal, + }); - await expect( - exportList({ - http: httpMock, - ...payload, - signal: abortCtrl.signal, + expect(httpMock.fetch).toHaveBeenCalledWith( + '/api/lists/index', + expect.objectContaining({ + method: 'GET', }) - ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "id"')); + ); }); - describe('readListIndex', () => { - beforeEach(() => { - httpMock.fetch.mockResolvedValue(getListItemIndexExistSchemaResponseMock()); + it('returns the response when valid', async () => { + const abortCtrl = new AbortController(); + const result = await readListIndex({ + http: httpMock, + signal: abortCtrl.signal, }); - it('GETs the list index', async () => { - const abortCtrl = new AbortController(); - await readListIndex({ - http: httpMock, - signal: abortCtrl.signal, - }); - - expect(httpMock.fetch).toHaveBeenCalledWith( - '/api/lists/index', - expect.objectContaining({ - method: 'GET', - }) - ); - }); + expect(result).toEqual(getListItemIndexExistSchemaResponseMock()); + }); + + it('rejects with an error if response payload is invalid', async () => { + const abortCtrl = new AbortController(); + const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined }; + httpMock.fetch.mockResolvedValue(badResponse); - it('returns the response when valid', async () => { - const abortCtrl = new AbortController(); - const result = await readListIndex({ + await expect( + readListIndex({ http: httpMock, signal: abortCtrl.signal, - }); - - expect(result).toEqual(getListItemIndexExistSchemaResponseMock()); - }); - - it('rejects with an error if response payload is invalid', async () => { - const abortCtrl = new AbortController(); - const badResponse = { ...getListItemIndexExistSchemaResponseMock(), list_index: undefined }; - httpMock.fetch.mockResolvedValue(badResponse); - - await expect( - readListIndex({ - http: httpMock, - signal: abortCtrl.signal, - }) - ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"')); - }); + }) + ).rejects.toEqual(new Error('Invalid value "undefined" supplied to "list_index"')); }); }); diff --git a/x-pack/plugins/lists/public/lists/api.ts b/x-pack/plugins/lists/public/lists/api.ts index a1efae2af877ae..606109f1910c45 100644 --- a/x-pack/plugins/lists/public/lists/api.ts +++ b/x-pack/plugins/lists/public/lists/api.ts @@ -59,6 +59,7 @@ const findLists = async ({ }; const findListsWithValidation = async ({ + cursor, http, pageIndex, pageSize, @@ -66,8 +67,9 @@ const findListsWithValidation = async ({ }: FindListsParams): Promise => pipe( { - page: String(pageIndex), - per_page: String(pageSize), + cursor: cursor?.toString(), + page: pageIndex?.toString(), + per_page: pageSize?.toString(), }, (payload) => fromEither(validateEither(findListSchema, payload)), chain((payload) => tryCatch(() => findLists({ http, signal, ...payload }), toError)), @@ -170,7 +172,6 @@ const exportListWithValidation = async ({ { list_id: listId }, (payload) => fromEither(validateEither(exportListItemQuerySchema, payload)), chain((payload) => tryCatch(() => exportList({ http, signal, ...payload }), toError)), - chain((response) => fromEither(validateEither(listSchema, response))), flow(toPromise) ); diff --git a/x-pack/plugins/lists/public/lists/types.ts b/x-pack/plugins/lists/public/lists/types.ts index 6421ad174d4d96..95a21820536e4b 100644 --- a/x-pack/plugins/lists/public/lists/types.ts +++ b/x-pack/plugins/lists/public/lists/types.ts @@ -14,6 +14,7 @@ export interface ApiParams { export type ApiPayload = Omit; export interface FindListsParams extends ApiParams { + cursor?: string | undefined; pageSize: number | undefined; pageIndex: number | undefined; } diff --git a/x-pack/plugins/lists/public/shared_exports.ts b/x-pack/plugins/lists/public/shared_exports.ts index dc2e28634e1e8c..57fb2f90b64045 100644 --- a/x-pack/plugins/lists/public/shared_exports.ts +++ b/x-pack/plugins/lists/public/shared_exports.ts @@ -13,6 +13,8 @@ export { useExceptionList } from './exceptions/hooks/use_exception_list'; export { useFindLists } from './lists/hooks/use_find_lists'; export { useImportList } from './lists/hooks/use_import_list'; export { useDeleteList } from './lists/hooks/use_delete_list'; +export { exportList } from './lists/api'; +export { useCursor } from './common/hooks/use_cursor'; export { useExportList } from './lists/hooks/use_export_list'; export { useReadListIndex } from './lists/hooks/use_read_list_index'; export { useCreateListIndex } from './lists/hooks/use_create_list_index'; diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts index f56f184a5a4677..a607906e1b92ab 100644 --- a/x-pack/plugins/security_solution/common/shared_imports.ts +++ b/x-pack/plugins/security_solution/common/shared_imports.ts @@ -39,4 +39,5 @@ export { entriesList, namespaceType, ExceptionListType, + Type, } from '../../lists/common'; diff --git a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts index 184aa4d8e673c8..2e0ac826c69472 100644 --- a/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts +++ b/x-pack/plugins/security_solution/public/common/lib/kibana/hooks.ts @@ -8,12 +8,13 @@ import moment from 'moment-timezone'; import { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; + import { DEFAULT_DATE_FORMAT, DEFAULT_DATE_FORMAT_TZ } from '../../../../common/constants'; -import { useUiSetting, useKibana } from './kibana_react'; import { errorToToaster, useStateToaster } from '../../components/toasters'; import { AuthenticatedUser } from '../../../../../security/common/model'; import { convertToCamelCase } from '../../../cases/containers/utils'; import { StartServices } from '../../../types'; +import { useUiSetting, useKibana } from './kibana_react'; export const useDateFormat = (): string => useUiSetting(DEFAULT_DATE_FORMAT); @@ -24,6 +25,11 @@ export const useTimeZone = (): string => { export const useBasePath = (): string => useKibana().services.http.basePath.get(); +export const useToasts = (): StartServices['notifications']['toasts'] => + useKibana().services.notifications.toasts; + +export const useHttp = (): StartServices['http'] => useKibana().services.http; + interface UserRealm { name: string; type: string; @@ -125,8 +131,3 @@ export const useGetUserSavedObjectPermissions = () => { return savedObjectsPermissions; }; - -export const useToasts = (): StartServices['notifications']['toasts'] => - useKibana().services.notifications.toasts; - -export const useHttp = (): StartServices['http'] => useKibana().services.http; diff --git a/x-pack/plugins/security_solution/public/common/utils/test_utils.ts b/x-pack/plugins/security_solution/public/common/utils/test_utils.ts new file mode 100644 index 00000000000000..5a3cddb74657d6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/common/utils/test_utils.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +// Temporary fix for https://github.com/enzymejs/enzyme/issues/2073 +export const waitForUpdates = async

(wrapper: ReactWrapper

) => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + wrapper.update(); + }); +}; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx new file mode 100644 index 00000000000000..ce5d19259e9eee --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.test.tsx @@ -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 React, { FormEvent } from 'react'; +import { mount, ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +import { waitForUpdates } from '../../../common/utils/test_utils'; +import { TestProviders } from '../../../common/mock'; +import { ValueListsForm } from './form'; +import { useImportList } from '../../../shared_imports'; + +jest.mock('../../../shared_imports'); +const mockUseImportList = useImportList as jest.Mock; + +const mockFile = ({ + name: 'foo.csv', + path: '/home/foo.csv', +} as unknown) as File; + +const mockSelectFile:

(container: ReactWrapper

, file: File) => Promise = async ( + container, + file +) => { + const fileChange = container.find('EuiFilePicker').prop('onChange'); + act(() => { + if (fileChange) { + fileChange(([file] as unknown) as FormEvent); + } + }); + await waitForUpdates(container); + expect( + container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled') + ).not.toEqual(true); +}; + +describe('ValueListsForm', () => { + let mockImportList: jest.Mock; + + beforeEach(() => { + mockImportList = jest.fn(); + mockUseImportList.mockImplementation(() => ({ + start: mockImportList, + })); + }); + + it('disables upload button when file is absent', () => { + const container = mount( + + + + ); + + expect( + container.find('button[data-test-subj="value-lists-form-import-action"]').prop('disabled') + ).toEqual(true); + }); + + it('calls importList when upload is clicked', async () => { + const container = mount( + + + + ); + + await mockSelectFile(container, mockFile); + + container.find('button[data-test-subj="value-lists-form-import-action"]').simulate('click'); + await waitForUpdates(container); + + expect(mockImportList).toHaveBeenCalledWith(expect.objectContaining({ file: mockFile })); + }); + + it('calls onError if import fails', async () => { + mockUseImportList.mockImplementation(() => ({ + start: jest.fn(), + error: 'whoops', + })); + + const onError = jest.fn(); + const container = mount( + + + + ); + await waitForUpdates(container); + + expect(onError).toHaveBeenCalledWith('whoops'); + }); + + it('calls onSuccess if import succeeds', async () => { + mockUseImportList.mockImplementation(() => ({ + start: jest.fn(), + result: { mockResult: true }, + })); + + const onSuccess = jest.fn(); + const container = mount( + + + + ); + await waitForUpdates(container); + + expect(onSuccess).toHaveBeenCalledWith({ mockResult: true }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx new file mode 100644 index 00000000000000..b8416c3242e4af --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/form.tsx @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useState, ReactNode, useEffect, useRef } from 'react'; +import styled from 'styled-components'; +import { + EuiButton, + EuiButtonEmpty, + EuiForm, + EuiFormRow, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiRadioGroup, +} from '@elastic/eui'; + +import { useImportList, ListSchema, Type } from '../../../shared_imports'; +import * as i18n from './translations'; +import { useKibana } from '../../../common/lib/kibana'; + +const InlineRadioGroup = styled(EuiRadioGroup)` + display: flex; + + .euiRadioGroup__item + .euiRadioGroup__item { + margin: 0 0 0 12px; + } +`; + +interface ListTypeOptions { + id: Type; + label: ReactNode; +} + +const options: ListTypeOptions[] = [ + { + id: 'keyword', + label: i18n.KEYWORDS_RADIO, + }, + { + id: 'ip', + label: i18n.IP_RADIO, + }, +]; + +const defaultListType: Type = 'keyword'; + +export interface ValueListsFormProps { + onError: (error: Error) => void; + onSuccess: (response: ListSchema) => void; +} + +export const ValueListsFormComponent: React.FC = ({ onError, onSuccess }) => { + const ctrl = useRef(new AbortController()); + const [files, setFiles] = useState(null); + const [type, setType] = useState(defaultListType); + const filePickerRef = useRef(null); + const { http } = useKibana().services; + const { start: importList, ...importState } = useImportList(); + + // EuiRadioGroup's onChange only infers 'string' from our options + const handleRadioChange = useCallback((t: string) => setType(t as Type), [setType]); + + const resetForm = useCallback(() => { + if (filePickerRef.current?.fileInput) { + filePickerRef.current.fileInput.value = ''; + filePickerRef.current.handleChange(); + } + setFiles(null); + setType(defaultListType); + }, [setType]); + + const handleCancel = useCallback(() => { + ctrl.current.abort(); + }, []); + + const handleSuccess = useCallback( + (response: ListSchema) => { + resetForm(); + onSuccess(response); + }, + [resetForm, onSuccess] + ); + const handleError = useCallback( + (error: Error) => { + onError(error); + }, + [onError] + ); + + const handleImport = useCallback(() => { + if (!importState.loading && files && files.length) { + ctrl.current = new AbortController(); + importList({ + file: files[0], + listId: undefined, + http, + signal: ctrl.current.signal, + type, + }); + } + }, [importState.loading, files, importList, http, type]); + + useEffect(() => { + if (!importState.loading && importState.result) { + handleSuccess(importState.result); + } else if (!importState.loading && importState.error) { + handleError(importState.error as Error); + } + }, [handleError, handleSuccess, importState.error, importState.loading, importState.result]); + + useEffect(() => { + return handleCancel; + }, [handleCancel]); + + return ( + + + + + + + + + + + + + + + + {importState.loading && ( + {i18n.CANCEL_BUTTON} + )} + + + + {i18n.UPLOAD_BUTTON} + + + + + + + + + ); +}; + +ValueListsFormComponent.displayName = 'ValueListsFormComponent'; + +export const ValueListsForm = React.memo(ValueListsFormComponent); + +ValueListsForm.displayName = 'ValueListsForm'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx new file mode 100644 index 00000000000000..1fbe0e312bd8ac --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/index.tsx @@ -0,0 +1,7 @@ +/* + * 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 { ValueListsModal } from './modal'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx new file mode 100644 index 00000000000000..daf1cbd68df915 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.test.tsx @@ -0,0 +1,63 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; + +import { TestProviders } from '../../../common/mock'; +import { ValueListsModal } from './modal'; +import { waitForUpdates } from '../../../common/utils/test_utils'; + +describe('ValueListsModal', () => { + it('renders nothing if showModal is false', () => { + const container = mount( + + + + ); + + expect(container.find('EuiModal')).toHaveLength(0); + }); + + it('renders modal if showModal is true', async () => { + const container = mount( + + + + ); + await waitForUpdates(container); + + expect(container.find('EuiModal')).toHaveLength(1); + }); + + it('calls onClose when modal is closed', async () => { + const onClose = jest.fn(); + const container = mount( + + + + ); + + container.find('button[data-test-subj="value-lists-modal-close-action"]').simulate('click'); + + await waitForUpdates(container); + + expect(onClose).toHaveBeenCalled(); + }); + + it('renders ValueListsForm and ValueListsTable', async () => { + const container = mount( + + + + ); + + await waitForUpdates(container); + + expect(container.find('ValueListsForm')).toHaveLength(1); + expect(container.find('ValueListsTable')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx new file mode 100644 index 00000000000000..0a935a9cdb1c45 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/modal.tsx @@ -0,0 +1,164 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + EuiButton, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiOverlayMask, + EuiSpacer, +} from '@elastic/eui'; + +import { + ListSchema, + exportList, + useFindLists, + useDeleteList, + useCursor, +} from '../../../shared_imports'; +import { useToasts, useKibana } from '../../../common/lib/kibana'; +import { GenericDownloader } from '../../../common/components/generic_downloader'; +import * as i18n from './translations'; +import { ValueListsTable } from './table'; +import { ValueListsForm } from './form'; + +interface ValueListsModalProps { + onClose: () => void; + showModal: boolean; +} + +export const ValueListsModalComponent: React.FC = ({ + onClose, + showModal, +}) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [cursor, setCursor] = useCursor({ pageIndex, pageSize }); + const { http } = useKibana().services; + const { start: findLists, ...lists } = useFindLists(); + const { start: deleteList, result: deleteResult } = useDeleteList(); + const [exportListId, setExportListId] = useState(); + const toasts = useToasts(); + + const fetchLists = useCallback(() => { + findLists({ cursor, http, pageIndex: pageIndex + 1, pageSize }); + }, [cursor, http, findLists, pageIndex, pageSize]); + + const handleDelete = useCallback( + ({ id }: { id: string }) => { + deleteList({ http, id }); + }, + [deleteList, http] + ); + + useEffect(() => { + if (deleteResult != null) { + fetchLists(); + } + }, [deleteResult, fetchLists]); + + const handleExport = useCallback( + async ({ ids }: { ids: string[] }) => + exportList({ http, listId: ids[0], signal: new AbortController().signal }), + [http] + ); + const handleExportClick = useCallback(({ id }: { id: string }) => setExportListId(id), []); + const handleExportComplete = useCallback(() => setExportListId(undefined), []); + + const handleTableChange = useCallback( + ({ page: { index, size } }: { page: { index: number; size: number } }) => { + setPageIndex(index); + setPageSize(size); + }, + [setPageIndex, setPageSize] + ); + const handleUploadError = useCallback( + (error: Error) => { + if (error.name !== 'AbortError') { + toasts.addError(error, { title: i18n.UPLOAD_ERROR }); + } + }, + [toasts] + ); + const handleUploadSuccess = useCallback( + (response: ListSchema) => { + toasts.addSuccess({ + text: i18n.uploadSuccessMessage(response.name), + title: i18n.UPLOAD_SUCCESS_TITLE, + }); + fetchLists(); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [toasts] + ); + + useEffect(() => { + if (showModal) { + fetchLists(); + } + }, [showModal, fetchLists]); + + useEffect(() => { + if (!lists.loading && lists.result?.cursor) { + setCursor(lists.result.cursor); + } + }, [lists.loading, lists.result, setCursor]); + + if (!showModal) { + return null; + } + + const pagination = { + pageIndex, + pageSize, + totalItemCount: lists.result?.total ?? 0, + hidePerPageOptions: true, + }; + + return ( + + + + {i18n.MODAL_TITLE} + + + + + + + + + {i18n.CLOSE_BUTTON} + + + + + + ); +}; + +ValueListsModalComponent.displayName = 'ValueListsModalComponent'; + +export const ValueListsModal = React.memo(ValueListsModalComponent); + +ValueListsModal.displayName = 'ValueListsModal'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx new file mode 100644 index 00000000000000..d0ed41ea58588d --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { mount } from 'enzyme'; +import { act } from 'react-dom/test-utils'; + +import { getListResponseMock } from '../../../../../lists/common/schemas/response/list_schema.mock'; +import { ListSchema } from '../../../../../lists/common/schemas/response'; +import { TestProviders } from '../../../common/mock'; +import { ValueListsTable } from './table'; + +describe('ValueListsTable', () => { + it('renders a row for each list', () => { + const lists = Array(3).fill(getListResponseMock()); + const container = mount( + + + + ); + + expect(container.find('tbody tr')).toHaveLength(3); + }); + + it('calls onChange when pagination is modified', () => { + const lists = Array(6).fill(getListResponseMock()); + const onChange = jest.fn(); + const container = mount( + + + + ); + + act(() => { + container.find('a[data-test-subj="pagination-button-next"]').simulate('click'); + }); + + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ page: expect.objectContaining({ index: 1 }) }) + ); + }); + + it('calls onExport when export is clicked', () => { + const lists = Array(3).fill(getListResponseMock()); + const onExport = jest.fn(); + const container = mount( + + + + ); + + act(() => { + container + .find('tbody tr') + .first() + .find('button[data-test-subj="action-export-value-list"]') + .simulate('click'); + }); + + expect(onExport).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' })); + }); + + it('calls onDelete when delete is clicked', () => { + const lists = Array(3).fill(getListResponseMock()); + const onDelete = jest.fn(); + const container = mount( + + + + ); + + act(() => { + container + .find('tbody tr') + .first() + .find('button[data-test-subj="action-delete-value-list"]') + .simulate('click'); + }); + + expect(onDelete).toHaveBeenCalledWith(expect.objectContaining({ id: 'some-list-id' })); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx new file mode 100644 index 00000000000000..07d52603a6fd10 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/table.tsx @@ -0,0 +1,103 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiBasicTable, EuiBasicTableProps, EuiText, EuiPanel } from '@elastic/eui'; + +import { ListSchema } from '../../../../../lists/common/schemas/response'; +import { FormattedDate } from '../../../common/components/formatted_date'; +import * as i18n from './translations'; + +type TableProps = EuiBasicTableProps; +type ActionCallback = (item: ListSchema) => void; + +export interface ValueListsTableProps { + lists: TableProps['items']; + loading: boolean; + onChange: TableProps['onChange']; + onExport: ActionCallback; + onDelete: ActionCallback; + pagination: Exclude; +} + +const buildColumns = ( + onExport: ActionCallback, + onDelete: ActionCallback +): TableProps['columns'] => [ + { + field: 'name', + name: i18n.COLUMN_FILE_NAME, + truncateText: true, + }, + { + field: 'created_at', + name: i18n.COLUMN_UPLOAD_DATE, + /* eslint-disable-next-line react/display-name */ + render: (value: ListSchema['created_at']) => ( + + ), + width: '30%', + }, + { + field: 'created_by', + name: i18n.COLUMN_CREATED_BY, + truncateText: true, + width: '20%', + }, + { + name: i18n.COLUMN_ACTIONS, + actions: [ + { + name: i18n.ACTION_EXPORT_NAME, + description: i18n.ACTION_EXPORT_DESCRIPTION, + icon: 'exportAction', + type: 'icon', + onClick: onExport, + 'data-test-subj': 'action-export-value-list', + }, + { + name: i18n.ACTION_DELETE_NAME, + description: i18n.ACTION_DELETE_DESCRIPTION, + icon: 'trash', + type: 'icon', + onClick: onDelete, + 'data-test-subj': 'action-delete-value-list', + }, + ], + width: '15%', + }, +]; + +export const ValueListsTableComponent: React.FC = ({ + lists, + loading, + onChange, + onExport, + onDelete, + pagination, +}) => { + const columns = buildColumns(onExport, onDelete); + return ( + + +

{i18n.TABLE_TITLE}

+ + + + ); +}; + +ValueListsTableComponent.displayName = 'ValueListsTableComponent'; + +export const ValueListsTable = React.memo(ValueListsTableComponent); + +ValueListsTable.displayName = 'ValueListsTable'; diff --git a/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts new file mode 100644 index 00000000000000..dca6e43a98143a --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/components/value_lists_management_modal/translations.ts @@ -0,0 +1,138 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const MODAL_TITLE = i18n.translate('xpack.securitySolution.lists.uploadValueListTitle', { + defaultMessage: 'Upload value lists', +}); + +export const FILE_PICKER_LABEL = i18n.translate( + 'xpack.securitySolution.lists.uploadValueListDescription', + { + defaultMessage: 'Upload single value lists to use while writing rules or rule exceptions.', + } +); + +export const FILE_PICKER_PROMPT = i18n.translate( + 'xpack.securitySolution.lists.uploadValueListPrompt', + { + defaultMessage: 'Select or drag and drop a file', + } +); + +export const CLOSE_BUTTON = i18n.translate( + 'xpack.securitySolution.lists.closeValueListsModalTitle', + { + defaultMessage: 'Close', + } +); + +export const CANCEL_BUTTON = i18n.translate( + 'xpack.securitySolution.lists.cancelValueListsUploadTitle', + { + defaultMessage: 'Cancel upload', + } +); + +export const UPLOAD_BUTTON = i18n.translate('xpack.securitySolution.lists.valueListsUploadButton', { + defaultMessage: 'Upload list', +}); + +export const UPLOAD_SUCCESS_TITLE = i18n.translate( + 'xpack.securitySolution.lists.valueListsUploadSuccessTitle', + { + defaultMessage: 'Value list uploaded', + } +); + +export const UPLOAD_ERROR = i18n.translate('xpack.securitySolution.lists.valueListsUploadError', { + defaultMessage: 'There was an error uploading the value list.', +}); + +export const uploadSuccessMessage = (fileName: string) => + i18n.translate('xpack.securitySolution.lists.valueListsUploadSuccess', { + defaultMessage: "Value list '{fileName}' was uploaded", + values: { fileName }, + }); + +export const COLUMN_FILE_NAME = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.fileNameColumn', + { + defaultMessage: 'Filename', + } +); + +export const COLUMN_UPLOAD_DATE = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.uploadDateColumn', + { + defaultMessage: 'Upload Date', + } +); + +export const COLUMN_CREATED_BY = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.createdByColumn', + { + defaultMessage: 'Created by', + } +); + +export const COLUMN_ACTIONS = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.actionsColumn', + { + defaultMessage: 'Actions', + } +); + +export const ACTION_EXPORT_NAME = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.exportActionName', + { + defaultMessage: 'Export', + } +); + +export const ACTION_EXPORT_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.exportActionDescription', + { + defaultMessage: 'Export value list', + } +); + +export const ACTION_DELETE_NAME = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.deleteActionName', + { + defaultMessage: 'Remove', + } +); + +export const ACTION_DELETE_DESCRIPTION = i18n.translate( + 'xpack.securitySolution.lists.valueListsTable.deleteActionDescription', + { + defaultMessage: 'Remove value list', + } +); + +export const TABLE_TITLE = i18n.translate('xpack.securitySolution.lists.valueListsTable.title', { + defaultMessage: 'Value lists', +}); + +export const LIST_TYPES_RADIO_LABEL = i18n.translate( + 'xpack.securitySolution.lists.valueListsForm.listTypesRadioLabel', + { + defaultMessage: 'Type of value list', + } +); + +export const IP_RADIO = i18n.translate('xpack.securitySolution.lists.valueListsForm.ipRadioLabel', { + defaultMessage: 'IP addresses', +}); + +export const KEYWORDS_RADIO = i18n.translate( + 'xpack.securitySolution.lists.valueListsForm.keywordsRadioLabel', + { + defaultMessage: 'Keywords', + } +); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx index 84c34f2bed93c8..0fce9e5ea3a44c 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/index.tsx @@ -22,6 +22,7 @@ import { useUserInfo } from '../../../components/user_info'; import { AllRules } from './all'; import { ImportDataModal } from '../../../../common/components/import_data_modal'; import { ReadOnlyCallOut } from '../../../components/rules/read_only_callout'; +import { ValueListsModal } from '../../../components/value_lists_management_modal'; import { UpdatePrePackagedRulesCallOut } from '../../../components/rules/pre_packaged_rules/update_callout'; import { getPrePackagedRuleStatus, redirectToDetections, userHasNoPermissions } from './helpers'; import * as i18n from './translations'; @@ -34,6 +35,9 @@ type Func = (refreshPrePackagedRule?: boolean) => void; const RulesPageComponent: React.FC = () => { const history = useHistory(); const [showImportModal, setShowImportModal] = useState(false); + const [isValueListsModalShown, setIsValueListsModalShown] = useState(false); + const showValueListsModal = useCallback(() => setIsValueListsModalShown(true), []); + const hideValueListsModal = useCallback(() => setIsValueListsModalShown(false), []); const refreshRulesData = useRef(null); const { loading: userInfoLoading, @@ -117,6 +121,7 @@ const RulesPageComponent: React.FC = () => { return ( <> {userHasNoPermissions(canUserCRUD) && } + setShowImportModal(false)} @@ -167,6 +172,15 @@ const RulesPageComponent: React.FC = () => {
)} + + + {i18n.UPLOAD_VALUE_LISTS} + + { mockuseMessagesStorage.mockImplementation(() => endpointNoticeMessage(false)); }); - it('renders the Setup Instructions text', () => { + it('renders the Setup Instructions text', async () => { const wrapper = mount( @@ -69,10 +70,11 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(true); }); - it('does not show Endpoint get ready button when ingest is not enabled', () => { + it('does not show Endpoint get ready button when ingest is not enabled', async () => { const wrapper = mount( @@ -80,10 +82,11 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(false); }); - it('shows Endpoint get ready button when ingest is enabled', () => { + it('shows Endpoint get ready button when ingest is enabled', async () => { (useIngestEnabledCheck as jest.Mock).mockReturnValue({ allEnabled: true }); const wrapper = mount( @@ -92,11 +95,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); expect(wrapper.find('[data-test-subj="empty-page-secondary-action"]').exists()).toBe(true); }); }); - it('it DOES NOT render the Getting started text when an index is available', () => { + it('it DOES NOT render the Getting started text when an index is available', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -113,10 +117,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="empty-page"]').exists()).toBe(false); }); - test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', () => { + test('it DOES render the Endpoint banner when the endpoint index is NOT available AND storage is NOT set', async () => { (useWithSource as jest.Mock).mockReturnValueOnce({ indicesExist: true, indexPattern: {}, @@ -138,10 +144,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(true); }); - test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', () => { + test('it does NOT render the Endpoint banner when the endpoint index is NOT available but storage is set', async () => { (useWithSource as jest.Mock).mockReturnValueOnce({ indicesExist: true, indexPattern: {}, @@ -163,10 +171,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); - test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', () => { + test('it does NOT render the Endpoint banner when the endpoint index is available AND storage is set', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -183,10 +193,12 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); - test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', () => { + test('it does NOT render the Endpoint banner when an index IS available but storage is NOT set', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -206,7 +218,7 @@ describe('Overview', () => { expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); - test('it does NOT render the Endpoint banner when Ingest is NOT available', () => { + test('it does NOT render the Endpoint banner when Ingest is NOT available', async () => { (useWithSource as jest.Mock).mockReturnValue({ indicesExist: true, indexPattern: {}, @@ -223,6 +235,8 @@ describe('Overview', () => { ); + await waitForUpdates(wrapper); + expect(wrapper.find('[data-test-subj="endpoint-prompt-banner"]').exists()).toBe(false); }); }); diff --git a/x-pack/plugins/security_solution/public/shared_imports.ts b/x-pack/plugins/security_solution/public/shared_imports.ts index 93edc484c3569a..fcd23ff9df4d83 100644 --- a/x-pack/plugins/security_solution/public/shared_imports.ts +++ b/x-pack/plugins/security_solution/public/shared_imports.ts @@ -27,12 +27,16 @@ export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/for export { ERROR_CODE } from '../../../../src/plugins/es_ui_shared/static/forms/helpers/field_validators/types'; export { + exportList, useIsMounted, + useCursor, useApi, useExceptionList, usePersistExceptionItem, usePersistExceptionList, useFindLists, + useDeleteList, + useImportList, useCreateListIndex, useReadListIndex, useReadListPrivileges, From 2009447ab8baf75255fea6334c392a53dee2f7bd Mon Sep 17 00:00:00 2001 From: Yuliia Naumenko Date: Mon, 13 Jul 2020 19:53:37 -0700 Subject: [PATCH 07/57] Added help text where needed on connectors and alert actions UI (#69601) * Added help text where needed on connectors and alert actions UI * fixed ui form * Added index action type examples, fixed slack link * Fixed email connector docs and links * Additional cleanup on email * Removed autofocus to avoid twice link click for opening in the new page * Extended documentation for es index action type * Fixed tests * Fixed doc link * fixed due to comments * fixed due to comments * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/actions/README.md Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/actions/README.md Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update x-pack/plugins/triggers_actions_ui/README.md Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/email.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/index.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Update docs/user/alerting/action-types/slack.asciidoc Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> * Fixed due to comments Co-authored-by: gchaps <33642766+gchaps@users.noreply.github.com> --- .../user/alerting/action-types/email.asciidoc | 119 ++++++++++++++++++ .../user/alerting/action-types/index.asciidoc | 38 +++++- .../user/alerting/action-types/slack.asciidoc | 20 +++ .../images/slack-add-webhook-integration.png | Bin 0 -> 109011 bytes .../images/slack-copy-webhook-url.png | Bin 0 -> 42332 bytes x-pack/plugins/actions/README.md | 19 ++- x-pack/plugins/triggers_actions_ui/README.md | 18 ++- .../email/email_connector.tsx | 15 ++- .../email/email_params.test.tsx | 2 + .../es_index/es_index_connector.tsx | 25 +++- .../es_index/es_index_params.test.tsx | 2 + .../es_index/es_index_params.tsx | 52 +++++--- .../pagerduty/pagerduty_params.test.tsx | 2 + .../server_log/server_log_params.test.tsx | 3 + .../servicenow/servicenow_params.test.tsx | 2 + .../slack/slack_connectors.tsx | 4 +- .../slack/slack_params.test.tsx | 2 + .../webhook/webhook_params.test.tsx | 2 + .../json_editor_with_message_variables.tsx | 3 + .../action_connector_form.tsx | 1 - .../action_connector_form/action_form.tsx | 1 + .../triggers_actions_ui/public/types.ts | 1 + 22 files changed, 288 insertions(+), 43 deletions(-) create mode 100644 docs/user/alerting/images/slack-add-webhook-integration.png create mode 100644 docs/user/alerting/images/slack-copy-webhook-url.png diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/user/alerting/action-types/email.asciidoc index 4fb8a816d1ec90..f6a02b9038c02b 100644 --- a/docs/user/alerting/action-types/email.asciidoc +++ b/docs/user/alerting/action-types/email.asciidoc @@ -77,3 +77,122 @@ Email actions have the following configuration properties: To, CC, BCC:: Each is a list of addresses. Addresses can be specified in `user@host-name` format, or in `name ` format. One of To, CC, or BCC must contain an entry. Subject:: The subject line of the email. Message:: The message text of the email. Markdown format is supported. + +[[configuring-email]] +==== Configuring email accounts + +The email action can send email using many popular SMTP email services. + +You configure the email action to send emails using the connector form. +For more information about configuring the email connector to work with different email +systems, refer to: + +* <> +* <> +* <> +* <> + +[float] +[[gmail]] +===== Sending email from Gmail + +Use the following email account settings to send email from the +https://mail.google.com[Gmail] SMTP service: + +[source,text] +-------------------------------------------------- + config: + host: smtp.gmail.com + port: 465 + secure: true + secrets: + user: + password: +-------------------------------------------------- +// CONSOLE + +If you get an authentication error that indicates that you need to continue the +sign-in process from a web browser when the action attempts to send email, you need +to configure Gmail to https://support.google.com/accounts/answer/6010255?hl=en[allow +less secure apps to access your account]. + +If two-step verification is enabled for your account, you must generate and use +a unique App Password to send email from {watcher}. See +https://support.google.com/accounts/answer/185833?hl=en[Sign in using App Passwords] +for more information. + +[float] +[[outlook]] +===== Sending email from Outlook.com + +Use the following email account settings to send email action from the +https://www.outlook.com/[Outlook.com] SMTP service: + +[source,text] +-------------------------------------------------- +config: + host: smtp-mail.outlook.com + port: 465 + secure: true +secrets: + user: + password: +-------------------------------------------------- + +When sending emails, you must provide a from address, either as the default +in your account configuration or as part of the email action in the watch. + +NOTE: You must use a unique App Password if two-step verification is enabled. + See http://windows.microsoft.com/en-us/windows/app-passwords-two-step-verification[App + passwords and two-step verification] for more information. + +[float] +[[amazon-ses]] +===== Sending email from Amazon SES (Simple Email Service) + +Use the following email account settings to send email from the +http://aws.amazon.com/ses[Amazon Simple Email Service] (SES) SMTP service: + +[source,text] +-------------------------------------------------- +config: + host: email-smtp.us-east-1.amazonaws.com <1> + port: 465 + secure: true +secrets: + user: + password: +-------------------------------------------------- +<1> `smtp.host` varies depending on the region + +NOTE: You must use your Amazon SES SMTP credentials to send email through + Amazon SES. For more information, see + http://docs.aws.amazon.com/ses/latest/DeveloperGuide/smtp-credentials.html[Obtaining + Your Amazon SES SMTP Credentials]. You might also need to verify + https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-email-addresses.html[your email address] + or https://docs.aws.amazon.com/ses/latest/DeveloperGuide/verify-domains.html[your whole domain] + at AWS. + +[float] +[[exchange]] +===== Sending email from Microsoft Exchange + +Use the following email account settings to send email action from Microsoft +Exchange: + +[source,text] +-------------------------------------------------- +config: + host: + port: 465 + secure: true + from: <1> +secrets: + user: <2> + password: +-------------------------------------------------- +<1> Some organizations configure Exchange to validate that the `from` field is a + valid local email account. +<2> Many organizations support use of your email address as your username. + Check with your system administrator if you receive + authentication-related failures. diff --git a/docs/user/alerting/action-types/index.asciidoc b/docs/user/alerting/action-types/index.asciidoc index 115423086bae3d..3a57c444943941 100644 --- a/docs/user/alerting/action-types/index.asciidoc +++ b/docs/user/alerting/action-types/index.asciidoc @@ -2,7 +2,7 @@ [[index-action-type]] === Index action -The index action type will index a document into {es}. +The index action type will index a document into {es}. See also the {ref}/indices-create-index.html[create index API]. [float] [[index-connector-configuration]] @@ -53,4 +53,38 @@ Execution time field:: This field will be automatically set to the time the ale Index actions have the following properties: -Document:: The document to index in json format. +Document:: The document to index in JSON format. + +Example of the index document for Index Threshold alert: + +[source,text] +-------------------------------------------------- +{ + "alert_id": "{{alertId}}", + "alert_name": "{{alertName}}", + "alert_instance_id": "{{alertInstanceId}}", + "context_message": "{{context.message}}" +} +-------------------------------------------------- + +Example of create test index using the API. + +[source,text] +-------------------------------------------------- +PUT test +{ + "settings" : { + "number_of_shards" : 1 + }, + "mappings" : { + "_doc" : { + "properties" : { + "alert_id" : { "type" : "text" }, + "alert_name" : { "type" : "text" }, + "alert_instance_id" : { "type" : "text" }, + "context_message": { "type" : "text" } + } + } + } +} +-------------------------------------------------- diff --git a/docs/user/alerting/action-types/slack.asciidoc b/docs/user/alerting/action-types/slack.asciidoc index 5bad8a53f898c4..99bf73c0f5597e 100644 --- a/docs/user/alerting/action-types/slack.asciidoc +++ b/docs/user/alerting/action-types/slack.asciidoc @@ -38,3 +38,23 @@ Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messa Slack actions have the following properties: Message:: The message text, converted to the `text` field in the Webhook JSON payload. Currently only the text field is supported. Markdown, images, and other advanced formatting are not yet supported. + +[[configuring-slack]] +==== Configuring Slack Accounts + +You configure the accounts Slack action type can use to communicate with Slack in the +connector form. + +You need a https://api.slack.com/incoming-webhooks[Slack webhook URL] to +configure a Slack account. To create a webhook +URL, set up an an **Incoming Webhook Integration** through the Slack console: + +. Log in to http://slack.com[slack.com] as a team administrator. +. Go to https://my.slack.com/services/new/incoming-webhook. +. Select a default channel for the integration. ++ +image::images/slack-add-webhook-integration.png[] +. Click *Add Incoming Webhook Integration*. +. Copy the generated webhook URL so you can paste it into your Slack connector form. ++ +image::images/slack-copy-webhook-url.png[] diff --git a/docs/user/alerting/images/slack-add-webhook-integration.png b/docs/user/alerting/images/slack-add-webhook-integration.png new file mode 100644 index 0000000000000000000000000000000000000000..347822ddd9fac4c88cd0c13aed8d991680c1b993 GIT binary patch literal 109011 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

|Do`&HRr1N={~j%nG_^Wb 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 literal 0 HcmV?d00001 diff --git a/docs/user/alerting/images/slack-copy-webhook-url.png b/docs/user/alerting/images/slack-copy-webhook-url.png new file mode 100644 index 0000000000000000000000000000000000000000..0acc9488e22a335e31a9e429bcc033425f11baac GIT binary patch literal 42332 zcmeFYWmp}-mNtq*aCawIaCi6M5D4xYcXti$0fM``y9al7ciFfGzdL7U&dhzj@7!m8 z+@GhP?%lP!q`OvCziX|xR);GoNFl-F!-Ii=A<0OKtAK&Q+Jn-0a4?|X8U~#+Ffc?Z z3o$Vz88IpFPaij5HlMm$AG^&apA<*d z)A7KZ%RE#pkjoH+t3E4Y;}DPIr~e!!7XXJNg@j>-kR#S-1Pl#Ig44V``i-^|OyiHL z;ZQD3-F~RuL5cE^fPKc^bq>hLM!5C`yE5wf_6rtlhTyca_)sRvoxdN3HHEZag>v%S zo)M+O_dWO|c~L^x-Au4J3;ry646t*m60?I_HnP|UhA>@9h*~HxBG!IzZ!|?P2dI8A zM<;4JmIg@VldWr$_aHVO|Ae72!k$yDZ=%R(V}(2&79VDO@6G#%G|$G%y&@PR|yPnPi9z9fnu*B^hzsYQ7RG&QIzuSfXoc4mDqTq*^pG34Rg9E8g<*2WKo#vp&evxwGujU#o-W@ z>G1De_J=wh55LZ3(fAaC%HAZ8ydQ8#QwoBLN!@7u@}i&kYO|$iNC+16BZYC%%c_Xl z=@UO&o(`-Qdh|NrZ!OJcXhaJrU>*LTyH`g|AHCh<=yDe()`_$9<=0BXNza+YP@LM0 zj!-)a!ttl+0Vnf^#n2(7WV=HAhWfS2)CyQ~w|yDN8g1fm{UHTfL;?Gvv(A9*h#S56nyyr-=YD zp2zwaBo1Z#=)COY!Z|w`M9L6UiZDZQsdXrnp0_I+ccj2Rq{l&@=lVBOMfb1f6#V2m z)D0N6is(2az9pg1l0mMxWOAxb&yO5?u5k;HD1&cgLi>ZMEuZXrrFx{T`B4WS{Iz1J zoB;Pijvx6Umu*$G`7J6+qoiL^2X>?sZK!Qx!Lb2~-IsB^OLqZXjxeUaQBzMJr;>R=yJ0_#gzcT;j^~Ap*Oj0L#dn`aXQKz^mgy2KHq-z%G%68FsM; zd~pwkKY#=dBG&*j4Ez%*wA>F}?B08NNY!3MW}HNS)*8eLm?Q(pWyGc+T>~a_sL&n< zdwdALybyhIBh%JDA?-yl|Ic;^YbDig$r_#_%kW+?g#YEio~ADLX8&?|6fJgLZ>;!{*2& zk!&P;UD&S#m1vPzn8=W5I!gH!wlKL&suGxFZ`ICIXcN^!P%4rKx77zb##D3tq=62MY{M+lS65?dSW5HVvY z%wEH_jc2;sMNZEF08p z8N6@L_Ikl?u}H{g%I(P&r`vLNny(t0+G5*MacEn){;n9yupl{owWB4$mmdVC2c4%$hVha+$OdgA?-(kq((hY&{WmGHl;(pKs+S z93{l(XfQY5n@;Yt4k<*|7CalTsjecf;;GWsxkO$f5wvulxjNjb-pE-0W2)X$O5;9~S3r);Gtk2)c56HUD%GBf3v(Q`61;>-7%727~UOV|q7H}<3R%le@DcmPuu*6JDbe-IuM z)-#ab&L;(uicBx&m|07?VtY})0BWpi=>G^`dBrf9Q5|W(VWShP;LqZhk)+{j;XRX% zf1ziqV65OzRsBNCLF-~=In^@lP(I`m z^@yv_`aTHt@(CJaQl33Eh3)$4bEZs(%*!mC#fSyptl+JYJ4Z*Bv#qC#Cs%f#exDy1 z9C`)1GyGdH-H$2OS2lGM{;9huozxXtc+8~8wthl~!ar*2;)@{_k1H%IYjrP5U zemc!LqI|uhT=|)hFhPwYXPdw)D_d}`>RnusHOmm0Xp=;7^XaCDAF{=;yTo2;{5$_R z%5e4Q2Q!DoX%|E%giP;%g~A-Ryq7wz`X8XPH?N=P7~&Y7lNo=;NLH%_HY=Tv`j|Cv zWuTt#@W<4OU@|Y4*9D10l>}YH=A>-al8&`HfAOE|*Q+NN%2mp`pXms%*wz|hOqfj8 zy8B(`=XuqIfwQ-OD!>6C8|M}$`vdTY39I{#VMJrLZQkkR8PE^-3E=t#%ed7gYA3N1 zF+?X-ZLKk&rPg+OekxNwl@v>}RTI)CSzFXeUY2K3Ik5_`68awTrTRPc_wy>wO1*Dy zzL^FaN%tZ^AXkbF&DxbkzWZLK{7yQ)<(cIu7Y5gnoo^eZ#aOC-r(flUWnH5+Fxlp( z&H371Q+CCDS>=Xdo3RCvR&fb%oyYg+K$omN?Ns-a>)iB$aSif;=Hc~8d)1EXLGr?8 z<*2jTxBLA46fz335J|ePA>=|*+1N|>GOTvmTN=u%;N7E5C_RcA5hs7g9o)V2 zb$nNTu#{JfOsosh6#sh*KxJ$L!&p&=kLSnkJ2sEkl(rOqqWd0^Zw3&)OGBI z;GvN3wfOtKlhn29^W2RY_LOCIyMWL<-lE6Tl!KpZM_kwO`Ijfz%j(l)WTwXtqPxd| zi`osMYLn_`Jtkk{SJSmMG&^liBQN5PF`uQEuDhV?&~uVbp{%FG*Aval`Kf{_EPcXm zcE9LX&uXUTYQ@on>u=iNh{)^|kp4?++k#}!V1s}bKip!$qf#j4Ylh>?G~`AEf?m?I?)R!))st^E&MAyJX(SKrdq$uu?pKrn;`K zO&1>@{iYrhJ4k!$h-dFoOmcn#YebbipkyG=HtZ8uQi?w@@JO*2Om)3Lw1H-2L zdw|QRe7*t$1JAPfrs1q1FUMECt@jqF^U1<1+&-sqp(e>^9^-QvIRWb5>w-2!!x>2D4b3nMerKidXX z<^Ow?SINR1V67o;VFR#r0^LK9g@>7){~s0pkF5W`<^QOv`9G?1vi{GS|0Cx=Yw|Pw z?ZN-((SL~RA6G&05`^bx`lsv#;c;zdKtl#bXd$ln4RnV1J2s$A8FbM6=NXj#R^_2j z$p{7}3??Hk^35ImEE6^bYhX4cGQ(+-Ck675zS}^A}lYAoIJuGN*Pn4mk>>& zHv>z#$!W9t zd9B$lS-|UI-$+&`>0eu+q}Tz!gAruI(J2dbS{+rItXDK5FP3g;wl9{FV__BlYlhY^ zeX7u?6$NG2F`)#!P=TY9{$KY12$>wYR92OI$!N%%b=qy>xa1^iiT`RPZVRfyUAshy zj8?y^Bk_yc*9c77pIhw*4>JGCPR{8;U+(m3wq9$J=d@j?@p`;Sf>i4-^w&19bI9WU zFH>1q;S;IN&1e!G^sY^#7ZeWlKPv?Q#(v=`cEe+RA+%2t)6@MIT?R*ACV?XzHtGvOqGHpl)M|VP6;t?w zOS?@K6@l>cpCBhp70}SYzEW=%J71xhV3FXV`Y&zk9zjxn=h3lbrsTgv18VoN7j1uw zk#_r#WNn%u*2^YCE`xi9?c3My+o!E6botH7?V3hr@G5SC4-MCPwU-X(r zO^YoKd;IICU0d^l81~toH?$r*!8qhl)1m7kuq2=RLQ#@E?@uL)h6t7$t&$N5xs|#y z?h4PAYsJ#oEy%?}5aY#A_}Dkg;-8N9ae;EL)_Eo-CgMf<9tTOYFEN|(*B$shU6S40 ztM#pDu~Bi6OkK-=J9a*0%jEZVpKP{3yCp9ZQEGPZ=DiQfjH_C5xz88~#?hsrkczpJ z8>^0YwOJd9z@p<%zw~u-YuyPU;Gf%#_U3!iKAFjvnm77*Iu%kW{F+p9@?F;>NiLm} z!Lp4n*Zq1|rAbGVR==vl!}e;t!D3GGbg>f1h|L>yblzed8=3ae6NDoD@^GGCXp_QP9$-?{lA|<8;rVJSP8UV`<>bk;TAoJfby|CvlJ$$@r7F`7ZAH9yp{! z1YtWzC7=JxV|RqQK($ywd5$OGFB~9$2KkRD1?3}7WdEG;|L?@I{wJlLi9Nc|k|kp; zB8qbL${Xue=aX4hn`d?rX9IFq-=kY-3&%GYa2!!v^2OW5tef|jOE2t7joLh6!l0a_ zB}s6u>hKV|SO{D0bPXr_Ew}SkwdeawoHktObtU!;kSD4H2B*}+0EUxVEd4KJ8FvGc z_F*68=~{Dj6bYB0<=Ea(EY6)O{uJtp`|oRNxPF7~`?k*HyVHd+As$QbsVo7E#O9L`g1IUHJn}v04 zv*`Z|QvUhg{zpPM;_rx};84*soZTN~}Iklj$aHRdR zi>~~8b-H zN2B?|SCs;tlW#oq#6#e620)T}N@Ml3Z`>~?v?^WW;NC_29RH1T(hLxM$(6qZ@YeE? z%k0BKrUbNnHVqZxAZHSG*d_NC?>1Vd@ zGp+fr)Q5X6ec1#mdC?%ZI8;g(tv*-!u&Ki!%KV6}NOx%0p1mf%nv0G(&E>+*!tLpt6 zzeZ099;vT%dX&$%-CCBqPnOcUSiju%?ia}|XHW5M)?R8gDo$r>jy5u+&pLCmEZ<=#h zoR^;EiyROy2howNWRqmqWNOpY`dqP6k&9Do{3au#+93;(ZRsh~=3S^I9=^B8(F!yh z3*4^9-)Ac<)_GQIlB@-sZ#JlL`rJn1F;?D}?UF?=@qMpKVi0<__*L#6W9mkyvy9mO z!Rb?e^bXvrllQpXtkL{RwD|2S$)?U|wu?7SbF}`O_2!c+UvLB#2eN0dZ1ViGu+5&1 z@zKD`M!Qoor|<{4SEC@fR5$AoAz{mC2$L`K7}R7GOYK>BDDe7QRIT`3MbcC^oz+&_ z=K}>kty9E#w?h1jAtKLe@e1jkaVJK#--K@AFpc8y1KmP9a1e*?rmpllRFr88Q~Q;V z7T2B}Gyh5DJ=R;6JL|b0kY|D*wx=vN3&tkyl3%ikjpdj$2p_k{BcmYFSXh+-0b^xfJJge2!&t5sS zd>~6Q!$1FNDl77svTRudh{Q7blL#T1TBj(9za&1OQ; zbg%EpUy6UN)hJqUuRCo|IA0(OsY3tZM_)n1uh7=oADj1Dht{uK)pdruZgs{p@GFg; zotVl;UFi2#sm3&Ziw>vb-#EEK4Hq}qJ<2xu(hH@7>kd648$}-568TieTsqwzzt=w+ z&)_E&o;ieuWJylfW3^ogrUkq>ZM^8q40a405=A_MT`bjU{o3oy{2o(_VCN^krN)*w zS$AhRQP7*;gF3zIMa-2^leWFF6Y?@ug#o`T#j_=6Q(1ZojhTN; z+xL0QiQC-_yHZDFsWj)nPQKS&fxhwuvhKXvqX>E0WW6TkKue)1*5ztFmwx$qjee!k zGV^S{F2_6xUPYz*O_@sV)Z4V{jH`GkeF=E3Q>(t%#Wpg3=}NC>!Kq*2N$GS^Lh)IP z*zW0xNz+}bs7UGMf>E+x{!T;rDBVk$WUogl_iaR_{j$*kqyq?Wlqa!;#kYSbQj1r< zzM*W%X7ak^6|H@u`(Bl}7bBpsrk6*cf5z%_aYoU+{&U6NviQl5&+U{Rq(krJY~TDU z4-S&`ALPcy(BNAXtqsn!zs6oXbZ@@4^OT3fz{oUdNQHNyvL+ZuaQ#%bIc&P zg8xJfG9fTYAwC-X+pmR$QU?cqG7w1OhoKKdhcutB=0wKQeQQE!%k6c3Uw{OJ`Z_NX zIVSj;H+_X4nSGHO3t{R?{(6*6%VF2PULgz4Mf>B$d*_b04^VxJRO_=zzK13Emq$+X zaRh5+z<3xD`4Hm^)?j_y<)hs;y$;c+v;H$C*F^)D~ftNqE zK3Gf@xrU9Bv9hcLwCDZ)`an2LW?4(;ZjCScvx{t2*F9z?bAEmtyP6407mwpkQl?oh z`P)wwjwdTBZ-bHJ^kD3B?phmg)VWsZ@hGyZE)PC#D z2JrpSFYnQOHTrRk(a6u8jJbJS(k} zDcg>YOZ9!Ia&umSi9X%!K%jjkU)9fuqcXTmNZEbeSTh6VZt9MymP@@-wwClnA-47F z&og562TXZ0X`LU^a?0)}kW6?Ycx$%&t|hp|;o@$IMq?{xBW~H>PMt&MJKiGBo?3CWuKxh*Y)rLG5Zt@s1V;3e!|3A_A=BTv z7Ul!C3(!~|@fN}-1aa|3Orb(E2i~%y=ub&#v`Ra^@n5o+I%>WYuq734R6tbm#`K2M z2|?-1v*odJK2LB~Pnx6QARTgNZXd-ZX4+nQ+a2bb@v%|?+{G>=-Zq0O%00SI<7;(?(Q>eWurVx- zGDzj2biWkFF=EdZ?mOW>wpZ@iG|B_;EvU<*S>8WFpe|V+`(q@=D3H#E&1betyT16V z*GiLjmoN$Q>pjl+)eneg63F%hlZIR%>jdFKUoaC!Vrg#x%C_#9XiJr=!qcK}M5*BC zxhgfmaRL#bWW>8KRvHGv0=50pE%4pR9+o;UO)+xKm+HI23ba`@2G(@j<$tJS0rYNi z2B|psnviA?k+xAYg2Z*gJT+{Am-S~<6L#bfj1Ql#5l#ruJhTpex*p=9*&v|7K=97m zi$ayWR&&OmYsY_z@2$;13>2qY8UdS9O!%&bh!Td0f^L7^QH`JQG|uIFpm(byi9E_x zvs>CMS3sAwQ)CxnqLrS>h)^|UYZ6PHBMGHrqtAdw{biM(Pk|~fQ}+7^ze}9>AdGnZ z_s)u-M8USgvsrWZu%j@&P_@>)pl{vp5L+we4Ovy$u8|SVTCTgkRSvh5Du>HrP`XkT zctXmkwGq;GVMfDn@f8DJOJd%7&POypi%oFkwB>pm%3n%?;K(mf!$Y3Jntsd)>%q5h zqnI?q@lju8Y&<THtXMCl-KU|u1(23wRYd*5xFgf1X@_KGNAkO+5C>*}27%xRk>jGRzQWSjeuV3G{qm~VVLE@h~ z8xAG$rD?vqk~|iLdp}sxFR)xFb7h%gQki%Md4z|TQzw3-nXNZd{@wox zMAJftfkCgo-5T{7du*yfHukzSvwK?Ds}P@E2}_vtKRiX|&n~I-YzSS#dd*|nGlT~| zrl+&!ZqIyKHtTquSu)uO-I5M&qLw~?*ERlKGe$lExK$*HZ9RKdWZymC8K=>MYcSt; zwmGQ3G{@{0XDjVB_Q}g5tYvHh7b6)!Ra5W0>cWeq-WFYl2Sy_1jRZ0VMRO-xLsIq+ zt`M1mnV?VXN2fSy1IoMVY*75;&VPKGlh(aYyDeC4JZi0rI(_c8MgZ>8=Zh zL;e-T^qdOcBiw6(W8JR>McMW@ID`V_qLMm88;~Wzx3i|`!uH`kKX6N&VY7Wx{7BCw zcIVS|Q}3Qz2(Qq#S5Hxxyz__1*b1D^I6~XwIj=Nzr~5RU>z#ji4+nL@lrsi{T_9A; zH!S;&mlk!B^U2Sj$@!}<5{84_U_go!Cx)>|FlQ>)!FIM`BEX`<1_njks;m{Q1u6TS zsgV7=jf3CGzO09Ye#J9nL&5jWcBx_c{ep!2W%gFC-KB*zL6SATB>rC2-QbNnLEaUW z&sEW~ z0|I@z)t6u1ULKRlB_iC5*5aALxda$=Js&TKD5+NONi~Gt|Li%AmKHC|u=s7c?;*9? z9s3Z+vzZ68O>&D76TaunS7qux!D~%s{z_g`Q18j)a?8@O?}ZLLwqCku{}6PN->?Wr zytIS30HMYHx>H@JjYCY!lIc_`!$1VqZ3Zi}4hnM2Unhha!~i}{A#}O-sGjzIXs=`1 zikO+His!}FhbV5HKc(dQ6Np=Xq)LLjeYC23_!P9#V%$7yv~#s$zc**=yUD5>OM zU$DJ35Ad#_D5!Vd1;8_>B4tS9SievDVY1bW++z$Cv1u{#f;SN*LodqhnL(CP!`ogG0@D-dB(3%9CNWnjDRC)#k z*gi(Vsq&gOudhUjGrr>tdhzUcaFMcv~^kTKR9qD=~ ze*TR+Q&yE?{6xA-gf>tTw30Y`O>RHa6IJ4IcZoOz!;SIT(t~z8P-+hcRw5i+1cEUJ zJy4bcczJeJfc-y0a)(I;L5&0q%P8qV^khhP>mb`$W5rJ~zz`(3YJQo9x^Ep!yf z7ZoIyG`1#tPr%qvFNgV(fpE!8+l00fnpoxwiNOs?iqs@iSb>ZiuGQhzDN|bsU_dnD z6MuZ=)P6{RD+v;*Zzh44qwe^VRSaEfRY@d`*1}y}Q^$k_t;1!*^Ftf1<>mKZo7Aux zttb+f`ynL?Vs*-n99y=B0|>0Q-b_u?%~cBDrAh#s{c?%YbQAn&GGT=gMIjUUNzrrD z>!t4+mMA~n0_Hb+#ztSpYv7_{k_D#y?e*6v4+{`~%}!&9@|UU(H@~046|(KPJ1=Ll z9qM{Nv?^68j3D%xpV^hXC3fgXySb)bAvSZxGKsNuai*hyS+n?qgznvJyYb}_x%K;C zEXgG!z7wwK6#`QiDlQS;=k3k)J=3Q{pj}>`S&%D)^4*D97IxUWF9NzfU&fq>V0upL z*nNgE9@LR?Z1hji8crx)ckGYZHuuXt&`iY$XEs76b5kBX(}C(*2U||MCp1P6qUTT1cyK^+sGY8P;A?DgrySv#KJ6hY9~<{I10& z7G#4VzZkxSvDOGSlEBNzm{l|qg%5#>(~cM(8eJHS^fLP@^aJS=^rK%zij&iKPwl9R-Q^TQ)v>FCs43+Q;uYV!m55MAFiaC7VMEl!MQ#knwO+j?nVyIw0;R zOI6gz_?-h%9y2Rb`7%ITElOZLW-zK}K=k;?Wsj>{I&}{@{u#wga(Fc<9s(Ds07SO3 z&MsR)k20h6RRKRHWWkptJz&Epm+kn$aZc*cC?k*B2h0>DCXs-vCF4fV3bxOZ&S_he zV*0~!c2U6#ucE+^sqQ7|ajh)R?|Bf6i@Lnl4Gt|<&x4Ruy6%U1yB||@;5eoJ%3NQ( zc6ZXq@6@Ma&iUXlHhj}&whpx9B_i6i*(#HvJL#->4dCi01PQTl(JY}9v4RYbtFf0> z5XGwlE;pBkePc8N#%tzb6M?xb%L#&~|S$8kjU$7K3$*hM3>GQWG zM^K$d=kaAsWA&LWbR`~tmg9gD>0Es|y?q5=wQxw)fw|RAWS{;@4z2ULvZlC16Wgy~gSC{rtSM?<+ z@{>R`gsmXDUY&b*hWl=~vRBLXAVH!2hp+M+Q}>x;tp9R{htt!Ikbc_1wA*>2U9tP( z(|hRUz*Y~h&$Ba6v6wS2I$W=Onh6)2@Lk98@5ue5Y`+I<42p#^3$vBBvr~?8zeKsnnS`i;?{}TQqC> zRVbSbV+-L^*tXm1mrM4D9B);9@B4WLaAUO=4&UO{0CwhRgCkOAw4}gy&h7y?qsv8` zx~LQBdr9z*w3@E6;geBRe9Rba)UCo=ulLRO2U7obKqn0d6elf)xcF7d1{m1eYxK*g za4YpZNEYm9ohMq|C=f(6P9X@OU5V`gvHuBo6~_st_#T1pdR%Xh-vf$i@k1)rxL-b( zt^ncZ49tpKyVJX~O`khPQx1Hp4N~)xMpJzOr--d1x|Ei z=EN2%ovSb^ZZEBPlOqnsej}Ut$FO!6fVaYuqF@OzCj7uScnoALb6oKVvGpw~iO(uE zCo%*xQ1OA!>-}{mqfm)U$6RZ7*QpEsmd!{<@NlhRT4Jr(CgwESrATuGXd?Mrgp1Su zs%Fzjf$vH|)_piaxI0jjR1-pkrNE1%g*~bi!3i?q*2S|osVL?M=e8D-9fKg6@Eo&E zUAtbS$JD{kaL#{%$nYVI3b#o0@~#<`A@|gnx~iv+OwPY^u-%MRMRw`U6yuIIk~)wV zkN3ahNt75k+UpzaTkp>t$~uU?S;0~Jz)D(l<| z9OrFnvRgff_O3JdTagI4BFK2$ytKEqx_bqiqMfpKN|5o3!km)6KTE?}+@Aj~KBl!-+mPnA9y!k0y$5zgEg1Wd75q@QRGe=8YE7|#k+{5Kw!Q)GaJeg zc{Y+IMAZVpcp4k#9+dj72RW*nxFJ14RBmS-)>DF#BRH!=k?(N1`)0#B!jG{r8S!@T zt*7zrYhUZANonsO#5i|f$rYH@U7(~%xfqT0&UtK_AiFUU!LRzGABGtivx7r*;+yxj zt^U4tqfd!|(e%uY$*V}H^E@^p8p)x(OYr#g4}t_SVhslhqqi>n)p ze5G&qi5qv#)*)dBaA(@z`Mg~XQwkeuy;uafu4u~oT)WZy;AZx?6&x*;_A0|?)24_~ z`#J2e2>Fy}I}WYDVW`GEl(Hmk?2&rhRV3{Y@w^7a%au7aS>(vZs#Zz@bj&S zYi;2X@7Z_AXV4f!cK%s*01URP?3u4@r>C&JuM8^(zDm!{A)-yXfj^;rU7Wcp~L86^Y7hKO+O>m|=`YmCdu)zy2L zE}R*KTI>Gu!`tHA&E`LYVSr;XqK!b(_Fx|&#=^H{!POW(6b1Ww z-UnxD2_!CW(YZORR2-9&W^=7me^-ij`sZDVvU7J(AOvx)&Lj#t@r>VT0aGW+7nL<~ z$3-l7yY_R=)=oE5Lm}5Q4ZP_sZ3{wf`yA5y8Y^iW{@tDN-?IvKvkl?iZ}&^C^RHd^ zi`tG-mwfzlHS1S9eFrUjbp#*N`j#KQZ_sb2Z4WneOtQem$_*JwM1r?n^C@<|*<3ML zi?yoC4DXwfI;SIrIh2cOuh3CYsMI<6Ax>#p_scuq+))J|MHt1QSX{8X=j;ge$cz+; z&8+aA1_{A^N+{*8bD^AWXW$}I5OJSF$JK9yEDP!KwWCQvh+GU&-e>mr+bN-g+9Bcu zec$(idU|pPvbd!Z{D6^fo_&13j z39Xwhw|lHBM1&CcptB1f1rQ8)q+;^zOG50LpnK!A!5D7(-}F>JxpUfeC=~e-OJU-uGV%CM6|g4 z;Krqm!IaiA(>9e;<@x)x8CgFbJFb{Ft&2Uz8}t0J@mtSOD5qe}j5PY^oNX~jhi+=X z&@R>?abOFyGObu=`3!R)FxU%8v)>vrny_Wjc8?-Ppt`E@Anuqc zPbmHn9ZjBDJl7wO5ZSd-vpR2{6@w=Pj!s-_pe_Th#r)4#Am=>4_A=*>x?LBmlw;WM zItX9!!Vls{y;j7Sr0|uubCjN^i%h*=$fI-A(MWbpK%Sq$b=Uy9{Zi*+68+Mk*1FgM zN9GrNnB7nA@krF%adUfIlQOl0i5OxT3lAG z6DGiD_fCbp(;duffy-M+t6@PQ;uPDKVvROo1FuFLWhpS&z_Xb%0WAB*b^ks@{9r@# z`RIcRBMFE^2Aarg-+;ocXC-m^es25{gX`I(wBWjTG9!bLZ8M6=uQWo+@4w~lf_}-< zB;!XDoAm#wSQuBdO%X9~ek;xGMNrmqXGBZ=@wUs`^8^>Y7M>zQU7QEIs93Z!3$I00 zXdNYJZ&RRKdzw^OS~TT6!9Nun&4n~U(|+WJXKgyvj_g6UOeKJhyAw$_MHFF_F8(3vkU=*r)MxSs>5aXgj za$?_n-0r=XER=S$xdmEfD69woy>%YRk6icUVI`;m6}fU^x}55qxOA)s>OH{}6XaMk zq>1_OP-sh9DgKeq6B!E4ct-;{(zY+Q{jR5oi!`)8Td=8}O`Ok5zhqm6(mT1xRSw&M zTHrXB<@f?j92Wi8U9@EYOsEd#Aex4oRyX;y<3T1QWbN_0=;#~pkoTWx_ekl*v>m-P zzeuAGwwYT>F86TRr*NP5q5$C?!0+9!@o2)enQf;US_%>c;tX6{a_T(;ofa=VkHyV; z%i6)``tO{0sHDQZq}#i?-FNRe^3OXrr_({w4`CK>1+Bi_B1#ehzwu8A7#bJ@1WT4W ztCer_2U8t8ug4zY`wN)sV^EKU+SIel$BiB~e{+4xV8ga#C(lE0u-owhzb7|7#hq2U z^x(3J@M%&KqAgz9BIbkAfookf`qaYXvy_h<>AYG)TFX59QOs(6Wo{R%5q=qcmxYq< zLYL`hy5GJg7j`|h*chQ+S%AmRx-_&~uKzY47J)m+#ouIM6aL`Qq5LAqiS|cxh2xC> z6~z1Oz7Y7x<&OvC3V=-(wyRgE_R>!@$mbwyK5ky9oKBzlbJ9mTHEn542;pO)HLx&c zQGVGTEJNrGAVhoiU5jMoXe7p4#5PQxlJ}Xn?R$eBYe*+yM;o{2w!G8vyEb!k=%%}l zDzM;#IL#>`Ak~HsF&y??P|D1*egnPh)o_Fk%2PpLh_C=FQ~fr2kV@NZHeKVK?)qUz zOWY^04(@ZSSXt2oyT+7iqdS{w%ver;c z4d#mmZRJqVPZl-?OP?2_UB6^0h7=A^up(|Mf12RUOgyXO{%}d<+n>2um2&lmvHc0I ztwce$yK8!`>)UY0tKL-TH&B@7J}9_y&NXs8M1p?#6!(1T;cYxQQy1+m;~9D|z0K+y zaKmj)A_~`I@Wjmhxua62wJ!Vt3B93a*G0FA&F5e>dcInJ3(mlmuv-MJMVev#@uDl> z?VeG)Np2G8p0&NM(+<*>2Qh+r4m{F5Hmen-{I}z55F+b#BBBo`#o3J2T@PhuGubg-lr>*twd8v#n}(=g-@;LAlEfk=v~A1;^Ka} z6cpp=eRnN2TVp6TbxqCp^D6}7a=VXswQjRIHoXQZo7rUI(oi?(X{^I}1;l$O&;^HS zwqcZa*#CNAqNJMO&8se%(L?3eIcL{)2^UK2!sFf@7C~$k?-b8Fo^qx>gVEkHKS~L7 z{YSaKY=`3uQfc`d%oz9&0>syT3G66QFf+`?ZrRKorMYZ3p2XV!IHLU_ z#hyhGoRfZmMz^yEyLous;U3Lhf`&PGXWVuI`b<@x`9SIL>lbYEJ<{jzs<9jt*!@;# zwZK&U=wIp8d1$k8!4e_{Jl=2PLwPPWVN)~9TWzsFjY2G5w?MGix3|*v6nau;GVBz>ga0(WeDoJ*V17GCg9YG% zIW%PHN!<+{Hg~iwzMl^ggoZNn2OCn|mq-%gHXEBsh$~<(hJ0y`-%KUKa~5iFc-u{N z_&Ghy9p;8wG!2X0k$n#P>iTxvWF#BsZ$+N9-C9YqW=-FS=yisxtjev0K*~Nw!MFu(E(ws| zZ0A_l@%!!OE{i!6#RzMD5t!1@nKdpsYK+;i zvoCd=*OeTDpWz@A8*Y9)aGx>F{w6OZoE`2Bpi!

xC)M&#l`e)|gwE**g_!EThN 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 = ({ action, editActionConfig, editActionSecrets, errors }) => { +>> = ({ action, editActionConfig, editActionSecrets, errors, docLinks }) => { const { from, host, port, secure } = action.config; const { user, password } = action.secrets; @@ -38,6 +40,17 @@ export const EmailActionConnectorFields: React.FunctionComponent + + + } > { @@ -22,6 +23,7 @@ describe('EmailParamsFields renders', () => { errors={{ to: [], cc: [], bcc: [], subject: [], message: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="toEmailAddressInput"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx index b5aa42cfd539ab..6fb078f3c808fa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_connector.tsx @@ -13,6 +13,7 @@ import { EuiSelect, EuiTitle, EuiIconTip, + EuiLink, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -28,7 +29,7 @@ import { const IndexActionConnectorFields: React.FunctionComponent> = ({ action, editActionConfig, errors, http }) => { +>> = ({ action, editActionConfig, errors, http, docLinks }) => { const { index, refresh, executionTimeField } = action.config; const [hasTimeFieldCheckbox, setTimeFieldCheckboxState] = useState( executionTimeField != null @@ -77,10 +78,22 @@ const IndexActionConnectorFields: React.FunctionComponent 0 && index !== undefined} error={errors.index} helpText={ - + <> + + + + + + } > } /> - {hasTimeFieldCheckbox ? ( <> + { test('all params fields is rendered', () => { @@ -18,6 +19,7 @@ describe('IndexParamsFields renders', () => { errors={{ index: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="documentsJsonEditor"]').first().prop('value')).toBe(`{ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx index fd6a3d64bd4be5..e8e8cc582512e5 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/es_index/es_index_params.tsx @@ -4,7 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import { EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; import { ActionParamsProps } from '../../../../types'; import { IndexActionParams } from '.././types'; import { JsonEditorWithMessageVariables } from '../../json_editor_with_message_variables'; @@ -14,6 +16,7 @@ export const IndexParamsFields = ({ index, editAction, messageVariables, + docLinks, }: ActionParamsProps) => { const { documents } = actionParams; @@ -26,26 +29,39 @@ export const IndexParamsFields = ({ }; return ( - 0 ? ((documents[0] as unknown) as string) : '' - } - label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.documentsFieldLabel', - { - defaultMessage: 'Document to index', + <> + 0 ? ((documents[0] as unknown) as string) : '' } - )} - aria-label={i18n.translate( - 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.jsonDocAriaLabel', - { - defaultMessage: 'Code editor', + label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.documentsFieldLabel', + { + defaultMessage: 'Document to index', + } + )} + aria-label={i18n.translate( + 'xpack.triggersActionsUI.components.builtinActionTypes.indexAction.jsonDocAriaLabel', + { + defaultMessage: 'Code editor', + } + )} + onDocumentsChange={onDocumentsChange} + helpText={ + + + } - )} - onDocumentsChange={onDocumentsChange} - /> + /> + ); }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx index 1b26b1157add9e..9e37047ccda507 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/pagerduty/pagerduty_params.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { EventActionOptions, SeverityActionOptions } from '.././types'; import PagerDutyParamsFields from './pagerduty_params'; +import { DocLinksStart } from 'kibana/public'; describe('PagerDutyParamsFields renders', () => { test('all params fields is rendered', () => { @@ -27,6 +28,7 @@ describe('PagerDutyParamsFields renders', () => { errors={{ summary: [], timestamp: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="severitySelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx index 1849a7ec9817ad..3a015cddcd335f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/server_log/server_log_params.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ServerLogLevelOptions } from '.././types'; import ServerLogParamsFields from './server_log_params'; +import { DocLinksStart } from 'kibana/public'; describe('ServerLogParamsFields renders', () => { test('all params fields is rendered', () => { @@ -21,6 +22,7 @@ describe('ServerLogParamsFields renders', () => { editAction={() => {}} index={0} defaultMessage={'test default message'} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); @@ -41,6 +43,7 @@ describe('ServerLogParamsFields renders', () => { errors={{ message: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="loggingLevelSelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx index 57d50cf7e5bdda..3ea628cd654731 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/servicenow/servicenow_params.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import ServiceNowParamsFields from './servicenow_params'; +import { DocLinksStart } from 'kibana/public'; describe('ServiceNowParamsFields renders', () => { test('all params fields is rendered', () => { @@ -29,6 +30,7 @@ describe('ServiceNowParamsFields renders', () => { editAction={() => {}} index={0} messageVariables={[]} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="urgencySelect"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx index 311ae587bbe13e..b6efd9fa932666 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/slack/slack_connectors.tsx @@ -12,7 +12,7 @@ import { SlackActionConnector } from '../types'; const SlackActionFields: React.FunctionComponent> = ({ action, editActionSecrets, errors }) => { +>> = ({ action, editActionSecrets, errors, docLinks }) => { const { webhookUrl } = action.secrets; return ( @@ -22,7 +22,7 @@ const SlackActionFields: React.FunctionComponent { test('all params fields is rendered', () => { @@ -18,6 +19,7 @@ describe('SlackParamsFields renders', () => { errors={{ message: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="messageTextArea"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx index 9e57d7ae608cc4..825c1372dfaf78 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_action_types/webhook/webhook_params.test.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import WebhookParamsFields from './webhook_params'; +import { DocLinksStart } from 'kibana/public'; describe('WebhookParamsFields renders', () => { test('all params fields is rendered', () => { @@ -18,6 +19,7 @@ describe('WebhookParamsFields renders', () => { errors={{ body: [] }} editAction={() => {}} index={0} + docLinks={{ ELASTIC_WEBSITE_URL: '', DOC_LINK_VERSION: '' } as DocLinksStart} /> ); expect(wrapper.find('[data-test-subj="bodyJsonEditor"]').length > 0).toBeTruthy(); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx index 2aac389dce5ecd..473c0fe9609ce6 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/json_editor_with_message_variables.tsx @@ -18,6 +18,7 @@ interface Props { errors?: string[]; areaLabel?: string; onDocumentsChange: (data: string) => void; + helpText?: JSX.Element; } export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ @@ -28,6 +29,7 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ errors, areaLabel, onDocumentsChange, + helpText, }) => { const [cursorPosition, setCursorPosition] = useState(null); @@ -65,6 +67,7 @@ export const JsonEditorWithMessageVariables: React.FunctionComponent = ({ paramsProperty={paramsProperty} /> } + helpText={helpText} > 0 && connector.name !== undefined} name="name" placeholder="Untitled" diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 7f400ee9a5db1e..9182d5a687eb51 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -313,6 +313,7 @@ export const ActionForm = ({ editAction={setActionParamsProperty} messageVariables={messageVariables} defaultMessage={defaultActionMessage ?? undefined} + docLinks={docLinks} /> ) : null} diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index a4a13d7ec849c6..fe3bf98b03230a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -42,6 +42,7 @@ export interface ActionParamsProps { errors: IErrorObject; messageVariables?: string[]; defaultMessage?: string; + docLinks: DocLinksStart; } export interface Pagination { From c86ad7bbec30e9d0e5bbf8fa2b9ef64fa1204551 Mon Sep 17 00:00:00 2001 From: Marshall Main <55718608+marshallmain@users.noreply.github.com> Date: Mon, 13 Jul 2020 23:06:48 -0400 Subject: [PATCH 08/57] Change signal.rule.risk score mapping from keyword to float (#71126) * Change risk_score mapping from keyword to float * Change default alert histogram option * Add version to signals template * Fix test * Undo histogram order change Co-authored-by: Elastic Machine --- .../lib/detection_engine/routes/index/get_signals_template.ts | 1 + .../lib/detection_engine/routes/index/signals_mapping.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts index 01d7182e253cec..cc22f34560c713 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/get_signals_template.ts @@ -25,6 +25,7 @@ export const getSignalsTemplate = (index: string) => { }, index_patterns: [`${index}-*`], mappings: ecsMapping.mappings, + version: 1, }; return template; }; diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json index aa4166e93f4a14..d600bae2746d98 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/index/signals_mapping.json @@ -68,7 +68,7 @@ "type": "keyword" }, "risk_score": { - "type": "keyword" + "type": "float" }, "risk_score_mapping": { "properties": { From f4091df289d3c64cf9f70edfa70ee8e04a8ba627 Mon Sep 17 00:00:00 2001 From: Pedro Jaramillo Date: Tue, 14 Jul 2020 05:39:58 +0200 Subject: [PATCH 09/57] [Security Solution][Exceptions] Exception modal bulk close alerts that match exception attributes (#71321) * progress on bulk close * works but could be slow * clean up, add tests * fix reduce types * address 'event.' fields * remove duplicate import * don't replace nested fields * my best friend typescript --- .../build_exceptions_query.test.ts | 1285 ++++++++++------- .../build_exceptions_query.ts | 57 +- .../detection_engine/get_query_filter.test.ts | 90 ++ .../detection_engine/get_query_filter.ts | 15 +- .../exceptions/add_exception_modal/index.tsx | 28 +- .../add_exception_modal/translations.ts | 8 + .../exceptions/edit_exception_modal/index.tsx | 12 +- .../edit_exception_modal/translations.ts | 8 + .../components/exceptions/helpers.test.tsx | 62 + .../common/components/exceptions/helpers.tsx | 30 + .../exceptions/use_add_exception.test.tsx | 99 ++ .../exceptions/use_add_exception.tsx | 29 +- 12 files changed, 1143 insertions(+), 580 deletions(-) diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts index ed0344207d18fd..26a219507c3aee 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.test.ts @@ -22,10 +22,82 @@ import { EntryMatch, EntryMatchAny, EntriesArray, + Operator, } from '../../../lists/common/schemas'; import { getExceptionListItemSchemaMock } from '../../../lists/common/schemas/response/exception_list_item_schema.mock'; describe('build_exceptions_query', () => { + let exclude: boolean; + const makeMatchEntry = ({ + field, + value = 'value-1', + operator = 'included', + }: { + field: string; + value?: string; + operator?: Operator; + }): EntryMatch => { + return { + field, + operator, + type: 'match', + value, + }; + }; + const makeMatchAnyEntry = ({ + field, + operator = 'included', + value = ['value-1', 'value-2'], + }: { + field: string; + operator?: Operator; + value?: string[]; + }): EntryMatchAny => { + return { + field, + operator, + value, + type: 'match_any', + }; + }; + const makeExistsEntry = ({ + field, + operator = 'included', + }: { + field: string; + operator?: Operator; + }): EntryExists => { + return { + field, + operator, + type: 'exists', + }; + }; + const matchEntryWithIncluded: EntryMatch = makeMatchEntry({ + field: 'host.name', + value: 'suricata', + }); + const matchEntryWithExcluded: EntryMatch = makeMatchEntry({ + field: 'host.name', + value: 'suricata', + operator: 'excluded', + }); + const matchAnyEntryWithIncludedAndTwoValues: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: ['suricata', 'auditd'], + }); + const existsEntryWithIncluded: EntryExists = makeExistsEntry({ + field: 'host.name', + }); + const existsEntryWithExcluded: EntryExists = makeExistsEntry({ + field: 'host.name', + operator: 'excluded', + }); + + beforeEach(() => { + exclude = true; + }); + describe('getLanguageBooleanOperator', () => { test('it returns value as uppercase if language is "lucene"', () => { const result = getLanguageBooleanOperator({ language: 'lucene', value: 'not' }); @@ -41,239 +113,376 @@ describe('build_exceptions_query', () => { }); describe('operatorBuilder', () => { - describe('kuery', () => { - test('it returns "not " when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'kuery' }); - - expect(operator).toEqual('not '); + describe("when 'exclude' is true", () => { + describe('and langauge is kuery', () => { + test('it returns "not " when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'kuery', exclude }); + expect(operator).toEqual('not '); + }); + test('it returns empty string when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'kuery', exclude }); + expect(operator).toEqual(''); + }); }); - test('it returns empty string when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'kuery' }); - - expect(operator).toEqual(''); + describe('and language is lucene', () => { + test('it returns "NOT " when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'lucene', exclude }); + expect(operator).toEqual('NOT '); + }); + test('it returns empty string when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'lucene', exclude }); + expect(operator).toEqual(''); + }); }); }); - - describe('lucene', () => { - test('it returns "NOT " when operator is "included"', () => { - const operator = operatorBuilder({ operator: 'included', language: 'lucene' }); - - expect(operator).toEqual('NOT '); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns empty string when operator is "excluded"', () => { - const operator = operatorBuilder({ operator: 'excluded', language: 'lucene' }); + describe('and language is kuery', () => { + test('it returns empty string when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'kuery', exclude }); + expect(operator).toEqual(''); + }); + test('it returns "not " when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'kuery', exclude }); + expect(operator).toEqual('not '); + }); + }); - expect(operator).toEqual(''); + describe('and language is lucene', () => { + test('it returns empty string when operator is "included"', () => { + const operator = operatorBuilder({ operator: 'included', language: 'lucene', exclude }); + expect(operator).toEqual(''); + }); + test('it returns "NOT " when operator is "excluded"', () => { + const operator = operatorBuilder({ operator: 'excluded', language: 'lucene', exclude }); + expect(operator).toEqual('NOT '); + }); }); }); }); describe('buildExists', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'excluded', field: 'host.name' }, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:*'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:*'); }); - - expect(query).toEqual('host.name:*'); }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'included', field: 'host.name' }, - language: 'kuery', + describe('lucene', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('_exists_host.name'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT _exists_host.name'); }); - - expect(query).toEqual('not host.name:*'); }); }); - describe('lucene', () => { - test('it returns formatted wildcard string when operator is "excluded"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'excluded', field: 'host.name' }, - language: 'lucene', - }); - - expect(query).toEqual('_exists_host.name'); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns formatted wildcard string when operator is "included"', () => { - const query = buildExists({ - item: { type: 'exists', operator: 'included', field: 'host.name' }, - language: 'lucene', + describe('kuery', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:*'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:*'); }); + }); - expect(query).toEqual('NOT _exists_host.name'); + describe('lucene', () => { + test('it returns formatted wildcard string when operator is "excluded"', () => { + const query = buildExists({ + item: existsEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT _exists_host.name'); + }); + test('it returns formatted wildcard string when operator is "included"', () => { + const query = buildExists({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('_exists_host.name'); + }); }); }); }); describe('buildMatch', () => { - describe('kuery', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'included', - field: 'host.name', - value: 'suricata', - }, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:suricata'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:suricata'); }); - - expect(query).toEqual('not host.name:suricata'); }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'excluded', - field: 'host.name', - value: 'suricata', - }, - language: 'kuery', + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT host.name:suricata'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('host.name:suricata'); }); - - expect(query).toEqual('host.name:suricata'); }); }); - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'included', - field: 'host.name', - value: 'suricata', - }, - language: 'lucene', - }); - - expect(query).toEqual('NOT host.name:suricata'); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns formatted string when operator is "excluded"', () => { - const query = buildMatch({ - item: { - type: 'match', - operator: 'excluded', - field: 'host.name', - value: 'suricata', - }, - language: 'lucene', + describe('kuery', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('host.name:suricata'); }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'kuery', + exclude, + }); + expect(query).toEqual('not host.name:suricata'); + }); + }); - expect(query).toEqual('host.name:suricata'); + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const query = buildMatch({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('host.name:suricata'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const query = buildMatch({ + item: matchEntryWithExcluded, + language: 'lucene', + exclude, + }); + expect(query).toEqual('NOT host.name:suricata'); + }); }); }); }); describe('buildMatchAny', () => { - describe('kuery', () => { - test('it returns empty string if given an empty array for "values"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: [], - type: 'match_any', - }, - language: 'kuery', - }); - - expect(exceptionSegment).toEqual(''); - }); + const entryWithIncludedAndNoValues: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: [], + }); + const entryWithIncludedAndOneValue: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: ['suricata'], + }); + const entryWithExcludedAndTwoValues: EntryMatchAny = makeMatchAnyEntry({ + field: 'host.name', + value: ['suricata', 'auditd'], + operator: 'excluded', + }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata'], - type: 'match_any', - }, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns empty string if given an empty array for "values"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndNoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual(''); }); - - expect(exceptionSegment).toEqual('not host.name:(suricata)'); - }); - - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'kuery', + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('not host.name:(suricata)'); + }); + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); }); - expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); + }); }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'excluded', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'kuery', + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('NOT host.name:(suricata)'); }); - - expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); }); }); - describe('lucene', () => { - test('it returns formatted string when operator is "included"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'lucene', - }); - - expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; }); - test('it returns formatted string when operator is "excluded"', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'excluded', - field: 'host.name', - value: ['suricata', 'auditd'], - type: 'match_any', - }, - language: 'lucene', + describe('kuery', () => { + test('it returns empty string if given an empty array for "values"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndNoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual(''); + }); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata)'); + }); + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata or auditd)'); }); - expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(exceptionSegment).toEqual('not host.name:(suricata or auditd)'); + }); }); - test('it returns formatted string when "values" includes only one item', () => { - const exceptionSegment = buildMatchAny({ - item: { - operator: 'included', - field: 'host.name', - value: ['suricata'], - type: 'match_any', - }, - language: 'lucene', + describe('lucene', () => { + test('it returns formatted string when operator is "included"', () => { + const exceptionSegment = buildMatchAny({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when operator is "excluded"', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithExcludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('NOT host.name:(suricata OR auditd)'); + }); + test('it returns formatted string when "values" includes only one item', () => { + const exceptionSegment = buildMatchAny({ + item: entryWithIncludedAndOneValue, + language: 'lucene', + exclude, + }); + expect(exceptionSegment).toEqual('host.name:(suricata)'); }); - - expect(exceptionSegment).toEqual('NOT host.name:(suricata)'); }); }); }); @@ -284,18 +493,11 @@ describe('build_exceptions_query', () => { const item: EntryNested = { field: 'parent', type: 'nested', - entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - ], + entries: [makeMatchEntry({ field: 'nestedField', operator: 'excluded' })], }; const result = buildNested({ item, language: 'kuery' }); - expect(result).toEqual('parent:{ nestedField:value-3 }'); + expect(result).toEqual('parent:{ nestedField:value-1 }'); }); test('it returns formatted query when multiple items in nested entry', () => { @@ -303,23 +505,13 @@ describe('build_exceptions_query', () => { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - { - field: 'nestedFieldB', - operator: 'excluded', - type: 'match', - value: 'value-4', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded' }), + makeMatchEntry({ field: 'nestedFieldB', operator: 'excluded', value: 'value-2' }), ], }; const result = buildNested({ item, language: 'kuery' }); - expect(result).toEqual('parent:{ nestedField:value-3 and nestedFieldB:value-4 }'); + expect(result).toEqual('parent:{ nestedField:value-1 and nestedFieldB:value-2 }'); }); }); @@ -329,18 +521,11 @@ describe('build_exceptions_query', () => { const item: EntryNested = { field: 'parent', type: 'nested', - entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - ], + entries: [makeMatchEntry({ field: 'nestedField', operator: 'excluded' })], }; const result = buildNested({ item, language: 'lucene' }); - expect(result).toEqual('parent:{ nestedField:value-3 }'); + expect(result).toEqual('parent:{ nestedField:value-1 }'); }); test('it returns formatted query when multiple items in nested entry', () => { @@ -348,129 +533,157 @@ describe('build_exceptions_query', () => { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, - { - field: 'nestedFieldB', - operator: 'excluded', - type: 'match', - value: 'value-4', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded' }), + makeMatchEntry({ field: 'nestedFieldB', operator: 'excluded', value: 'value-2' }), ], }; const result = buildNested({ item, language: 'lucene' }); - expect(result).toEqual('parent:{ nestedField:value-3 AND nestedFieldB:value-4 }'); + expect(result).toEqual('parent:{ nestedField:value-1 AND nestedFieldB:value-2 }'); }); }); }); describe('evaluateValues', () => { - describe('kuery', () => { - test('it returns formatted wildcard string when "type" is "exists"', () => { - const list: EntryExists = { - operator: 'included', - type: 'exists', - field: 'host.name', - }; - const result = evaluateValues({ - item: list, - language: 'kuery', + describe("when 'exclude' is true", () => { + describe('kuery', () => { + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = evaluateValues({ + item: existsEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(result).toEqual('not host.name:*'); }); - - expect(result).toEqual('not host.name:*'); - }); - - test('it returns formatted string when "type" is "match"', () => { - const list: EntryMatch = { - operator: 'included', - type: 'match', - field: 'host.name', - value: 'suricata', - }; - const result = evaluateValues({ - item: list, - language: 'kuery', + test('it returns formatted string when "type" is "match"', () => { + const result = evaluateValues({ + item: matchEntryWithIncluded, + language: 'kuery', + exclude, + }); + expect(result).toEqual('not host.name:suricata'); + }); + test('it returns formatted string when "type" is "match_any"', () => { + const result = evaluateValues({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, + }); + expect(result).toEqual('not host.name:(suricata or auditd)'); }); - - expect(result).toEqual('not host.name:suricata'); }); - test('it returns formatted string when "type" is "match_any"', () => { - const list: EntryMatchAny = { - operator: 'included', - type: 'match_any', - field: 'host.name', - value: ['suricata', 'auditd'], - }; - - const result = evaluateValues({ - item: list, - language: 'kuery', + describe('lucene', () => { + describe('kuery', () => { + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = evaluateValues({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('NOT _exists_host.name'); + }); + test('it returns formatted string when "type" is "match"', () => { + const result = evaluateValues({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('NOT host.name:suricata'); + }); + test('it returns formatted string when "type" is "match_any"', () => { + const result = evaluateValues({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(result).toEqual('NOT host.name:(suricata OR auditd)'); + }); }); - - expect(result).toEqual('not host.name:(suricata or auditd)'); }); }); - describe('lucene', () => { + describe("when 'exclude' is false", () => { + beforeEach(() => { + exclude = false; + }); + describe('kuery', () => { test('it returns formatted wildcard string when "type" is "exists"', () => { - const list: EntryExists = { - operator: 'included', - type: 'exists', - field: 'host.name', - }; const result = evaluateValues({ - item: list, - language: 'lucene', + item: existsEntryWithIncluded, + language: 'kuery', + exclude, }); - - expect(result).toEqual('NOT _exists_host.name'); + expect(result).toEqual('host.name:*'); }); - test('it returns formatted string when "type" is "match"', () => { - const list: EntryMatch = { - operator: 'included', - type: 'match', - field: 'host.name', - value: 'suricata', - }; const result = evaluateValues({ - item: list, - language: 'lucene', + item: matchEntryWithIncluded, + language: 'kuery', + exclude, }); - - expect(result).toEqual('NOT host.name:suricata'); + expect(result).toEqual('host.name:suricata'); }); - test('it returns formatted string when "type" is "match_any"', () => { - const list: EntryMatchAny = { - operator: 'included', - type: 'match_any', - field: 'host.name', - value: ['suricata', 'auditd'], - }; - const result = evaluateValues({ - item: list, - language: 'lucene', + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'kuery', + exclude, }); + expect(result).toEqual('host.name:(suricata or auditd)'); + }); + }); - expect(result).toEqual('NOT host.name:(suricata OR auditd)'); + describe('lucene', () => { + describe('kuery', () => { + test('it returns formatted wildcard string when "type" is "exists"', () => { + const result = evaluateValues({ + item: existsEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('_exists_host.name'); + }); + test('it returns formatted string when "type" is "match"', () => { + const result = evaluateValues({ + item: matchEntryWithIncluded, + language: 'lucene', + exclude, + }); + expect(result).toEqual('host.name:suricata'); + }); + test('it returns formatted string when "type" is "match_any"', () => { + const result = evaluateValues({ + item: matchAnyEntryWithIncludedAndTwoValues, + language: 'lucene', + exclude, + }); + expect(result).toEqual('host.name:(suricata OR auditd)'); + }); }); }); }); }); describe('formatQuery', () => { + describe('when query is empty string', () => { + test('it returns query if "exceptions" is empty array', () => { + const formattedQuery = formatQuery({ exceptions: [], query: '', language: 'kuery' }); + expect(formattedQuery).toEqual(''); + }); + test('it returns expected query string when single exception in array', () => { + const formattedQuery = formatQuery({ + exceptions: ['b:(value-1 or value-2) and not c:*'], + query: '', + language: 'kuery', + }); + expect(formattedQuery).toEqual('(b:(value-1 or value-2) and not c:*)'); + }); + }); + test('it returns query if "exceptions" is empty array', () => { const formattedQuery = formatQuery({ exceptions: [], query: 'a:*', language: 'kuery' }); - expect(formattedQuery).toEqual('a:*'); }); @@ -480,7 +693,6 @@ describe('build_exceptions_query', () => { query: 'a:*', language: 'kuery', }); - expect(formattedQuery).toEqual('(a:* and b:(value-1 or value-2) and not c:*)'); }); @@ -490,7 +702,6 @@ describe('build_exceptions_query', () => { query: 'a:*', language: 'kuery', }); - expect(formattedQuery).toEqual( '(a:* and b:(value-1 or value-2) and not c:*) or (a:* and not d:*)' ); @@ -502,6 +713,7 @@ describe('build_exceptions_query', () => { const query = buildExceptionItemEntries({ language: 'kuery', lists: [], + exclude, }); expect(query).toEqual(''); @@ -511,22 +723,13 @@ describe('build_exceptions_query', () => { // Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c) // https://www.dcode.fr/boolean-expressions-calculator const payload: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchAnyEntry({ field: 'b' }), + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists: payload, + exclude, }); const expectedQuery = 'not b:(value-1 or value-2) and c:value-3'; @@ -537,28 +740,19 @@ describe('build_exceptions_query', () => { // Equal to query && !(b || !c) -> (query AND NOT b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), ], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 }'; @@ -569,33 +763,20 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), ], }, - { - field: 'd', - operator: 'included', - type: 'exists', - }, + makeExistsEntry({ field: 'd' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:(value-1 or value-2) and parent:{ nestedField:value-3 } and not d:*'; @@ -606,72 +787,151 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value-1', 'value-2'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'nestedField', - operator: 'excluded', - type: 'match', - value: 'value-3', - }, + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), ], }, - { - field: 'e', - operator: 'excluded', - type: 'exists', - }, + makeExistsEntry({ field: 'e', operator: 'excluded' }), ]; const query = buildExceptionItemEntries({ language: 'lucene', lists, + exclude, }); const expectedQuery = 'NOT b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND _exists_e'; expect(query).toEqual(expectedQuery); }); - describe('exists', () => { - test('it returns expected query when list includes single list item with operator of "included"', () => { - // Equal to query && !(b) -> (query AND NOT b) + describe('when "exclude" is false', () => { + beforeEach(() => { + exclude = false; + }); + + test('it returns empty string if empty lists array passed in', () => { + const query = buildExceptionItemEntries({ + language: 'kuery', + lists: [], + exclude, + }); + + expect(query).toEqual(''); + }); + test('it returns expected query when more than one item in list', () => { + // Equal to query && !(b && !c) -> (query AND NOT b) OR (query AND c) + // https://www.dcode.fr/boolean-expressions-calculator + const payload: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-3' }), + ]; + const query = buildExceptionItemEntries({ + language: 'kuery', + lists: payload, + exclude, + }); + const expectedQuery = 'b:(value-1 or value-2) and not c:value-3'; + + expect(query).toEqual(expectedQuery); + }); + + test('it returns expected query when list item includes nested value', () => { + // Equal to query && !(b || !c) -> (query AND NOT b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), { - field: 'b', - operator: 'included', - type: 'exists', + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + ], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:*'; + const expectedQuery = 'b:(value-1 or value-2) and parent:{ nestedField:value-3 }'; expect(query).toEqual(expectedQuery); }); - test('it returns expected query when list includes single list item with operator of "excluded"', () => { - // Equal to query && !(!b) -> (query AND b) + test('it returns expected query when list includes multiple items and nested "and" values', () => { + // Equal to query && !((b || !c) && d) -> (query AND NOT b AND c) OR (query AND NOT d) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), { - field: 'b', - operator: 'excluded', - type: 'exists', + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + ], }, + makeExistsEntry({ field: 'd' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, + }); + const expectedQuery = 'b:(value-1 or value-2) and parent:{ nestedField:value-3 } and d:*'; + expect(query).toEqual(expectedQuery); + }); + + test('it returns expected query when language is "lucene"', () => { + // Equal to query && !((b || !c) && !d) -> (query AND NOT b AND c) OR (query AND d) + // https://www.dcode.fr/boolean-expressions-calculator + const lists: EntriesArray = [ + makeMatchAnyEntry({ field: 'b' }), + { + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'nestedField', operator: 'excluded', value: 'value-3' }), + ], + }, + makeExistsEntry({ field: 'e', operator: 'excluded' }), + ]; + const query = buildExceptionItemEntries({ + language: 'lucene', + lists, + exclude, + }); + const expectedQuery = + 'b:(value-1 OR value-2) AND parent:{ nestedField:value-3 } AND NOT _exists_e'; + expect(query).toEqual(expectedQuery); + }); + }); + + describe('exists', () => { + test('it returns expected query when list includes single list item with operator of "included"', () => { + // Equal to query && !(b) -> (query AND NOT b) + // https://www.dcode.fr/boolean-expressions-calculator + const lists: EntriesArray = [makeExistsEntry({ field: 'b' })]; + const query = buildExceptionItemEntries({ + language: 'kuery', + lists, + exclude, + }); + const expectedQuery = 'not b:*'; + + expect(query).toEqual(expectedQuery); + }); + + test('it returns expected query when list includes single list item with operator of "excluded"', () => { + // Equal to query && !(!b) -> (query AND b) + // https://www.dcode.fr/boolean-expressions-calculator + const lists: EntriesArray = [makeExistsEntry({ field: 'b', operator: 'excluded' })]; + const query = buildExceptionItemEntries({ + language: 'kuery', + lists, + exclude, }); const expectedQuery = 'b:*'; @@ -682,27 +942,17 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b || !c) -> (query AND b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'exists', - }, + makeExistsEntry({ field: 'b', operator: 'excluded' }), { field: 'parent', type: 'nested', - entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'value-1', - }, - ], + entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-1' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'b:* and parent:{ c:value-1 }'; @@ -713,38 +963,21 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'exists', - }, + makeExistsEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'value-1', - }, - { - field: 'd', - operator: 'included', - type: 'match', - value: 'value-2', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'value-1' }), + makeMatchEntry({ field: 'd', value: 'value-2' }), ], }, - { - field: 'e', - operator: 'included', - type: 'exists', - }, + makeExistsEntry({ field: 'e' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:* and parent:{ c:value-1 and d:value-2 } and not e:*'; @@ -756,17 +989,11 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { // Equal to query && !(b) -> (query AND NOT b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match', - value: 'value', - }, - ]; + const lists: EntriesArray = [makeMatchEntry({ field: 'b', value: 'value' })]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'not b:value'; @@ -777,16 +1004,12 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b) -> (query AND b) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match', - value: 'value', - }, + makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'b:value'; @@ -797,28 +1020,17 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b || !c) -> (query AND b AND c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match', - value: 'value', - }, + makeMatchEntry({ field: 'b', operator: 'excluded', value: 'value' }), { field: 'parent', type: 'nested', - entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - ], + entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); const expectedQuery = 'b:value and parent:{ c:valueC }'; @@ -829,42 +1041,23 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c || d) && e) -> (query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match', - value: 'value', - }, + makeMatchEntry({ field: 'b', value: 'value' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - { - field: 'd', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), ], }, - { - field: 'e', - operator: 'included', - type: 'match', - value: 'valueC', - }, + makeMatchEntry({ field: 'e', value: 'valueE' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:value and parent:{ c:valueC and d:valueC } and not e:valueC'; + const expectedQuery = 'not b:value and parent:{ c:valueC and d:valueD } and not e:valueE'; expect(query).toEqual(expectedQuery); }); @@ -874,19 +1067,13 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "included"', () => { // Equal to query && !(b) -> (query AND NOT b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, - ]; + const lists: EntriesArray = [makeMatchAnyEntry({ field: 'b' })]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:(value or value-1)'; + const expectedQuery = 'not b:(value-1 or value-2)'; expect(query).toEqual(expectedQuery); }); @@ -894,19 +1081,13 @@ describe('build_exceptions_query', () => { test('it returns expected query when list includes single list item with operator of "excluded"', () => { // Equal to query && !(!b) -> (query AND b) // https://www.dcode.fr/boolean-expressions-calculator - const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match_any', - value: ['value', 'value-1'], - }, - ]; + const lists: EntriesArray = [makeMatchAnyEntry({ field: 'b', operator: 'excluded' })]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'b:(value or value-1)'; + const expectedQuery = 'b:(value-1 or value-2)'; expect(query).toEqual(expectedQuery); }); @@ -915,30 +1096,19 @@ describe('build_exceptions_query', () => { // Equal to query && !(!b || c) -> (query AND b AND NOT c) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'excluded', - type: 'match_any', - value: ['value', 'value-1'], - }, + makeMatchAnyEntry({ field: 'b', operator: 'excluded' }), { field: 'parent', type: 'nested', - entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - ], + entries: [makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' })], }, ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'b:(value or value-1) and parent:{ c:valueC }'; + const expectedQuery = 'b:(value-1 or value-2) and parent:{ c:valueC }'; expect(query).toEqual(expectedQuery); }); @@ -947,24 +1117,15 @@ describe('build_exceptions_query', () => { // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) // https://www.dcode.fr/boolean-expressions-calculator const lists: EntriesArray = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, - { - field: 'e', - operator: 'included', - type: 'match_any', - value: ['valueE', 'value-4'], - }, + makeMatchAnyEntry({ field: 'b' }), + makeMatchAnyEntry({ field: 'c' }), ]; const query = buildExceptionItemEntries({ language: 'kuery', lists, + exclude, }); - const expectedQuery = 'not b:(value or value-1) and not e:(valueE or value-4)'; + const expectedQuery = 'not b:(value-1 or value-2) and not c:(value-1 or value-2)'; expect(query).toEqual(expectedQuery); }); @@ -985,36 +1146,16 @@ describe('build_exceptions_query', () => { const payload = getExceptionListItemSchemaMock(); const payload2 = getExceptionListItemSchemaMock(); payload2.entries = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - { - field: 'd', - operator: 'excluded', - type: 'match', - value: 'valueD', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), ], }, - { - field: 'e', - operator: 'included', - type: 'match_any', - value: ['valueE', 'value-4'], - }, + makeMatchAnyEntry({ field: 'e' }), ]; const query = buildQueryExceptions({ query: 'a:*', @@ -1022,7 +1163,7 @@ describe('build_exceptions_query', () => { lists: [payload, payload2], }); const expectedQuery = - '(a:* and some.parentField:{ nested.field:some value } and not some.not.nested.field:some value) or (a:* and not b:(value or value-1) and parent:{ c:valueC and d:valueD } and not e:(valueE or value-4))'; + '(a:* and some.parentField:{ nested.field:some value } and not some.not.nested.field:some value) or (a:* and not b:(value-1 or value-2) and parent:{ c:valueC and d:valueD } and not e:(value-1 or value-2))'; expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); }); @@ -1033,36 +1174,16 @@ describe('build_exceptions_query', () => { const payload = getExceptionListItemSchemaMock(); const payload2 = getExceptionListItemSchemaMock(); payload2.entries = [ - { - field: 'b', - operator: 'included', - type: 'match_any', - value: ['value', 'value-1'], - }, + makeMatchAnyEntry({ field: 'b' }), { field: 'parent', type: 'nested', entries: [ - { - field: 'c', - operator: 'excluded', - type: 'match', - value: 'valueC', - }, - { - field: 'd', - operator: 'excluded', - type: 'match', - value: 'valueD', - }, + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), ], }, - { - field: 'e', - operator: 'included', - type: 'match_any', - value: ['valueE', 'value-4'], - }, + makeMatchAnyEntry({ field: 'e' }), ]; const query = buildQueryExceptions({ query: 'a:*', @@ -1070,9 +1191,85 @@ describe('build_exceptions_query', () => { lists: [payload, payload2], }); const expectedQuery = - '(a:* AND some.parentField:{ nested.field:some value } AND NOT some.not.nested.field:some value) OR (a:* AND NOT b:(value OR value-1) AND parent:{ c:valueC AND d:valueD } AND NOT e:(valueE OR value-4))'; + '(a:* AND some.parentField:{ nested.field:some value } AND NOT some.not.nested.field:some value) OR (a:* AND NOT b:(value-1 OR value-2) AND parent:{ c:valueC AND d:valueD } AND NOT e:(value-1 OR value-2))'; expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); }); + + describe('when "exclude" is false', () => { + beforeEach(() => { + exclude = false; + }); + + test('it returns original query if lists is empty array', () => { + const query = buildQueryExceptions({ + query: 'host.name: *', + language: 'kuery', + lists: [], + exclude, + }); + const expectedQuery = 'host.name: *'; + + expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); + }); + + test('it returns expected query when lists exist and language is "kuery"', () => { + // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) + // https://www.dcode.fr/boolean-expressions-calculator + const payload = getExceptionListItemSchemaMock(); + const payload2 = getExceptionListItemSchemaMock(); + payload2.entries = [ + makeMatchAnyEntry({ field: 'b' }), + { + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), + ], + }, + makeMatchAnyEntry({ field: 'e' }), + ]; + const query = buildQueryExceptions({ + query: 'a:*', + language: 'kuery', + lists: [payload, payload2], + exclude, + }); + const expectedQuery = + '(a:* and some.parentField:{ nested.field:some value } and some.not.nested.field:some value) or (a:* and b:(value-1 or value-2) and parent:{ c:valueC and d:valueD } and e:(value-1 or value-2))'; + + expect(query).toEqual([{ query: expectedQuery, language: 'kuery' }]); + }); + + test('it returns expected query when lists exist and language is "lucene"', () => { + // Equal to query && !((b || !c || d) && e) -> ((query AND NOT b AND c AND NOT d) OR (query AND NOT e) + // https://www.dcode.fr/boolean-expressions-calculator + const payload = getExceptionListItemSchemaMock(); + const payload2 = getExceptionListItemSchemaMock(); + payload2.entries = [ + makeMatchAnyEntry({ field: 'b' }), + { + field: 'parent', + type: 'nested', + entries: [ + makeMatchEntry({ field: 'c', operator: 'excluded', value: 'valueC' }), + makeMatchEntry({ field: 'd', operator: 'excluded', value: 'valueD' }), + ], + }, + makeMatchAnyEntry({ field: 'e' }), + ]; + const query = buildQueryExceptions({ + query: 'a:*', + language: 'lucene', + lists: [payload, payload2], + exclude, + }); + const expectedQuery = + '(a:* AND some.parentField:{ nested.field:some value } AND some.not.nested.field:some value) OR (a:* AND b:(value-1 OR value-2) AND parent:{ c:valueC AND d:valueD } AND e:(value-1 OR value-2))'; + + expect(query).toEqual([{ query: expectedQuery, language: 'lucene' }]); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts index d3ac5d1490703d..a70e6a66385899 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/build_exceptions_query.ts @@ -17,6 +17,7 @@ import { entriesMatch, entriesNested, ExceptionListItemSchema, + CreateExceptionListItemSchema, } from '../shared_imports'; import { Language, Query } from './schemas/common/schemas'; @@ -45,32 +46,35 @@ export const getLanguageBooleanOperator = ({ export const operatorBuilder = ({ operator, language, + exclude, }: { operator: Operator; language: Language; + exclude: boolean; }): string => { const not = getLanguageBooleanOperator({ language, value: 'not', }); - switch (operator) { - case 'included': - return `${not} `; - default: - return ''; + if ((exclude && operator === 'included') || (!exclude && operator === 'excluded')) { + return `${not} `; + } else { + return ''; } }; export const buildExists = ({ item, language, + exclude, }: { item: EntryExists; language: Language; + exclude: boolean; }): string => { const { operator, field } = item; - const exceptionOperator = operatorBuilder({ operator, language }); + const exceptionOperator = operatorBuilder({ operator, language, exclude }); switch (language) { case 'kuery': @@ -85,12 +89,14 @@ export const buildExists = ({ export const buildMatch = ({ item, language, + exclude, }: { item: EntryMatch; language: Language; + exclude: boolean; }): string => { const { value, operator, field } = item; - const exceptionOperator = operatorBuilder({ operator, language }); + const exceptionOperator = operatorBuilder({ operator, language, exclude }); return `${exceptionOperator}${field}:${value}`; }; @@ -98,9 +104,11 @@ export const buildMatch = ({ export const buildMatchAny = ({ item, language, + exclude, }: { item: EntryMatchAny; language: Language; + exclude: boolean; }): string => { const { value, operator, field } = item; @@ -109,7 +117,7 @@ export const buildMatchAny = ({ return ''; default: const or = getLanguageBooleanOperator({ language, value: 'or' }); - const exceptionOperator = operatorBuilder({ operator, language }); + const exceptionOperator = operatorBuilder({ operator, language, exclude }); const matchAnyValues = value.map((v) => v); return `${exceptionOperator}${field}:(${matchAnyValues.join(` ${or} `)})`; @@ -133,16 +141,18 @@ export const buildNested = ({ export const evaluateValues = ({ item, language, + exclude, }: { item: Entry | EntryNested; language: Language; + exclude: boolean; }): string => { if (entriesExists.is(item)) { - return buildExists({ item, language }); + return buildExists({ item, language, exclude }); } else if (entriesMatch.is(item)) { - return buildMatch({ item, language }); + return buildMatch({ item, language, exclude }); } else if (entriesMatchAny.is(item)) { - return buildMatchAny({ item, language }); + return buildMatchAny({ item, language, exclude }); } else if (entriesNested.is(item)) { return buildNested({ item, language }); } else { @@ -163,7 +173,11 @@ export const formatQuery = ({ const or = getLanguageBooleanOperator({ language, value: 'or' }); const and = getLanguageBooleanOperator({ language, value: 'and' }); const formattedExceptions = exceptions.map((exception) => { - return `(${query} ${and} ${exception})`; + if (query === '') { + return `(${exception})`; + } else { + return `(${query} ${and} ${exception})`; + } }); return formattedExceptions.join(` ${or} `); @@ -175,15 +189,17 @@ export const formatQuery = ({ export const buildExceptionItemEntries = ({ lists, language, + exclude, }: { lists: EntriesArray; language: Language; + exclude: boolean; }): string => { const and = getLanguageBooleanOperator({ language, value: 'and' }); const exceptionItem = lists .filter(({ type }) => type !== 'list') .reduce((accum, listItem) => { - const exceptionSegment = evaluateValues({ item: listItem, language }); + const exceptionSegment = evaluateValues({ item: listItem, language, exclude }); return [...accum, exceptionSegment]; }, []); @@ -194,15 +210,22 @@ export const buildQueryExceptions = ({ query, language, lists, + exclude = true, }: { query: Query; language: Language; - lists: ExceptionListItemSchema[] | undefined; + lists: Array | undefined; + exclude?: boolean; }): DataQuery[] => { if (lists != null) { - const exceptions = lists.map((exceptionItem) => - buildExceptionItemEntries({ lists: exceptionItem.entries, language }) - ); + const exceptions = lists.reduce((acc, exceptionItem) => { + return [ + ...acc, + ...(exceptionItem.entries !== undefined + ? [buildExceptionItemEntries({ lists: exceptionItem.entries, language, exclude })] + : []), + ]; + }, []); const formattedQuery = formatQuery({ exceptions, language, query }); return [ { diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts index 6edd2489e90c95..c19ef45605f83f 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.test.ts @@ -456,6 +456,96 @@ describe('get_filter', () => { }); }); + describe('when "excludeExceptions" is false', () => { + test('it should work with a list', () => { + const esQuery = getQueryFilter( + 'host.name: linux', + 'kuery', + [], + ['auditbeat-*'], + [getExceptionListItemSchemaMock()], + false + ); + expect(esQuery).toEqual({ + bool: { + filter: [ + { + bool: { + filter: [ + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'host.name': 'linux', + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + nested: { + path: 'some.parentField', + query: { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.parentField.nested.field': 'some value', + }, + }, + ], + }, + }, + score_mode: 'none', + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + match: { + 'some.not.nested.field': 'some value', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], + must: [], + must_not: [], + should: [], + }, + }); + }); + + test('it should work with an empty list', () => { + const esQuery = getQueryFilter('host.name: linux', 'kuery', [], ['auditbeat-*'], [], false); + expect(esQuery).toEqual({ + bool: { + filter: [ + { bool: { minimum_should_match: 1, should: [{ match: { 'host.name': 'linux' } }] } }, + ], + must: [], + must_not: [], + should: [], + }, + }); + }); + }); + test('it should work with a nested object queries', () => { const esQuery = getQueryFilter( 'category:{ name:Frank and trusted:true }', diff --git a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts index ef390c3b449395..6584373b806d8e 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/get_query_filter.ts @@ -11,7 +11,10 @@ import { buildEsQuery, Query as DataQuery, } from '../../../../../src/plugins/data/common'; -import { ExceptionListItemSchema } from '../../../lists/common/schemas'; +import { + ExceptionListItemSchema, + CreateExceptionListItemSchema, +} from '../../../lists/common/schemas'; import { buildQueryExceptions } from './build_exceptions_query'; import { Query, Language, Index } from './schemas/common/schemas'; @@ -20,14 +23,20 @@ export const getQueryFilter = ( language: Language, filters: Array>, index: Index, - lists: ExceptionListItemSchema[] + lists: Array, + excludeExceptions: boolean = true ) => { const indexPattern: IIndexPattern = { fields: [], title: index.join(), }; - const queries: DataQuery[] = buildQueryExceptions({ query, language, lists }); + const queries: DataQuery[] = buildQueryExceptions({ + query, + language, + lists, + exclude: excludeExceptions, + }); const config = { allowLeadingWildcards: true, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index 10d510c5f56c3f..d5eeef0f1e7682 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -251,13 +251,19 @@ export const AddExceptionModal = memo(function AddExceptionModal({ const onAddExceptionConfirm = useCallback(() => { if (addOrUpdateExceptionItems !== null) { - if (shouldCloseAlert && alertData) { - addOrUpdateExceptionItems(enrichExceptionItems(), alertData.ecsData._id); - } else { - addOrUpdateExceptionItems(enrichExceptionItems()); - } + const alertIdToClose = shouldCloseAlert && alertData ? alertData.ecsData._id : undefined; + const bulkCloseIndex = + shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined; + addOrUpdateExceptionItems(enrichExceptionItems(), alertIdToClose, bulkCloseIndex); } - }, [addOrUpdateExceptionItems, enrichExceptionItems, shouldCloseAlert, alertData]); + }, [ + addOrUpdateExceptionItems, + enrichExceptionItems, + shouldCloseAlert, + shouldBulkCloseAlert, + alertData, + signalIndexName, + ]); const isSubmitButtonDisabled = useCallback( () => fetchOrCreateListError || exceptionItemsToAdd.length === 0, @@ -330,7 +336,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ {alertData !== undefined && ( - + )} - + { if (addOrUpdateExceptionItems !== null) { - addOrUpdateExceptionItems(enrichExceptionItems()); + const bulkCloseIndex = + shouldBulkCloseAlert && signalIndexName !== null ? [signalIndexName] : undefined; + addOrUpdateExceptionItems(enrichExceptionItems(), undefined, bulkCloseIndex); } - }, [addOrUpdateExceptionItems, enrichExceptionItems]); + }, [addOrUpdateExceptionItems, enrichExceptionItems, shouldBulkCloseAlert, signalIndexName]); const indexPatternConfig = useCallback(() => { if (exceptionListType === 'endpoint') { @@ -239,10 +241,12 @@ export const EditExceptionModal = memo(function EditExceptionModal({ - + { expect(result).toEqual(true); }); }); + + describe('#prepareExceptionItemsForBulkClose', () => { + test('it should return no exceptionw when passed in an empty array', () => { + const payload: ExceptionListItemSchema[] = []; + const result = prepareExceptionItemsForBulkClose(payload); + expect(result).toEqual([]); + }); + + test("should not make any updates when the exception entries don't contain 'event.'", () => { + const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; + const result = prepareExceptionItemsForBulkClose(payload); + expect(result).toEqual(payload); + }); + + test("should update entry fields when they start with 'event.'", () => { + const payload = [ + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'event.kind', + }, + getEntryMatchMock(), + ], + }, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'event.module', + }, + ], + }, + ]; + const expected = [ + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'signal.original_event.kind', + }, + getEntryMatchMock(), + ], + }, + { + ...getExceptionListItemSchemaMock(), + entries: [ + { + ...getEntryMatchMock(), + field: 'signal.original_event.module', + }, + ], + }, + ]; + const result = prepareExceptionItemsForBulkClose(payload); + expect(result).toEqual(expected); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index 481b2736b75975..3d028431de8ffd 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -36,6 +36,7 @@ import { exceptionListItemSchema, UpdateExceptionListItemSchema, ExceptionListType, + EntryNested, } from '../../../lists_plugin_deps'; import { IFieldType, IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { TimelineNonEcsData } from '../../../graphql/types'; @@ -380,6 +381,35 @@ export const formatExceptionItemForUpdate = ( }; }; +/** + * Maps "event." fields to "signal.original_event.". This is because when a rule is created + * the "event" field is copied over to "original_event". When the user creates an exception, + * they expect it to match against the original_event's fields, not the signal event's. + * @param exceptionItems new or existing ExceptionItem[] + */ +export const prepareExceptionItemsForBulkClose = ( + exceptionItems: Array +): Array => { + return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { + if (item.entries !== undefined) { + const newEntries = item.entries.map((itemEntry: Entry | EntryNested) => { + return { + ...itemEntry, + field: itemEntry.field.startsWith('event.') + ? itemEntry.field.replace(/^event./, 'signal.original_event.') + : itemEntry.field, + }; + }); + return { + ...item, + entries: newEntries, + }; + } else { + return item; + } + }); +}; + /** * Adds new and existing comments to all new exceptionItems if not present already * @param exceptionItems new or existing ExceptionItem[] diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx index 018ca1d29c369b..bf07ff21823ebd 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.test.tsx @@ -9,6 +9,8 @@ import { KibanaServices } from '../../../common/lib/kibana'; import * as alertsApi from '../../../detections/containers/detection_engine/alerts/api'; import * as listsApi from '../../../../../lists/public/exceptions/api'; +import * as getQueryFilterHelper from '../../../../common/detection_engine/get_query_filter'; +import * as buildAlertStatusFilterHelper from '../../../detections/components/alerts_table/default_config'; import { getExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/response/exception_list_item_schema.mock'; import { getCreateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/create_exception_list_item_schema.mock'; import { getUpdateExceptionListItemSchemaMock } from '../../../../../lists/common/schemas/request/update_exception_list_item_schema.mock'; @@ -38,11 +40,16 @@ describe('useAddOrUpdateException', () => { let updateExceptionListItem: jest.SpyInstance>; + let getQueryFilter: jest.SpyInstance>; + let buildAlertStatusFilter: jest.SpyInstance>; let addOrUpdateItemsArgs: Parameters; let render: () => RenderHookResult; const onError = jest.fn(); const onSuccess = jest.fn(); const alertIdToClose = 'idToClose'; + const bulkCloseIndex = ['.signals']; const itemsToAdd: CreateExceptionListItemSchema[] = [ { ...getCreateExceptionListItemSchemaMock(), @@ -113,6 +120,10 @@ describe('useAddOrUpdateException', () => { .spyOn(listsApi, 'updateExceptionListItem') .mockResolvedValue(getExceptionListItemSchemaMock()); + getQueryFilter = jest.spyOn(getQueryFilterHelper, 'getQueryFilter'); + + buildAlertStatusFilter = jest.spyOn(buildAlertStatusFilterHelper, 'buildAlertStatusFilter'); + addOrUpdateItemsArgs = [itemsToAddOrUpdate]; render = () => renderHook(() => @@ -244,4 +255,92 @@ describe('useAddOrUpdateException', () => { }); }); }); + + describe('when bulkCloseIndex is passed in', () => { + beforeEach(() => { + addOrUpdateItemsArgs = [itemsToAddOrUpdate, undefined, bulkCloseIndex]; + }); + it('should update the status of only alerts that are open', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(buildAlertStatusFilter).toHaveBeenCalledTimes(1); + expect(buildAlertStatusFilter.mock.calls[0][0]).toEqual('open'); + }); + }); + it('should generate the query filter using exceptions', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(getQueryFilter).toHaveBeenCalledTimes(1); + expect(getQueryFilter.mock.calls[0][4]).toEqual(itemsToAddOrUpdate); + expect(getQueryFilter.mock.calls[0][5]).toEqual(false); + }); + }); + it('should update the alert status', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(updateAlertStatus).toHaveBeenCalledTimes(1); + }); + }); + it('creates new items', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(addExceptionListItem).toHaveBeenCalledTimes(2); + expect(addExceptionListItem.mock.calls[1][0].listItem).toEqual(itemsToAdd[1]); + }); + }); + it('updates existing items', async () => { + await act(async () => { + const { rerender, result, waitForNextUpdate } = render(); + const addOrUpdateItems = await waitForAddOrUpdateFunc({ + rerender, + result, + waitForNextUpdate, + }); + if (addOrUpdateItems) { + addOrUpdateItems(...addOrUpdateItemsArgs); + } + await waitForNextUpdate(); + expect(updateExceptionListItem).toHaveBeenCalledTimes(2); + expect(updateExceptionListItem.mock.calls[1][0].listItem).toEqual( + itemsToUpdateFormatted[1] + ); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx index 267a9afd9cf6d2..55c3ea35716d51 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_add_exception.tsx @@ -16,18 +16,23 @@ import { } from '../../../lists_plugin_deps'; import { updateAlertStatus } from '../../../detections/containers/detection_engine/alerts/api'; import { getUpdateAlertsQuery } from '../../../detections/components/alerts_table/actions'; -import { formatExceptionItemForUpdate } from './helpers'; +import { buildAlertStatusFilter } from '../../../detections/components/alerts_table/default_config'; +import { getQueryFilter } from '../../../../common/detection_engine/get_query_filter'; +import { Index } from '../../../../common/detection_engine/schemas/common/schemas'; +import { formatExceptionItemForUpdate, prepareExceptionItemsForBulkClose } from './helpers'; /** * Adds exception items to the list. Also optionally closes alerts. * * @param exceptionItemsToAddOrUpdate array of ExceptionListItemSchema to add or update * @param alertIdToClose - optional string representing alert to close + * @param bulkCloseIndex - optional index used to create bulk close query * */ export type AddOrUpdateExceptionItemsFunc = ( exceptionItemsToAddOrUpdate: Array, - alertIdToClose?: string + alertIdToClose?: string, + bulkCloseIndex?: Index ) => Promise; export type ReturnUseAddOrUpdateException = [ @@ -100,7 +105,8 @@ export const useAddOrUpdateException = ({ const addOrUpdateExceptionItems: AddOrUpdateExceptionItemsFunc = async ( exceptionItemsToAddOrUpdate, - alertIdToClose + alertIdToClose, + bulkCloseIndex ) => { try { setIsLoading(true); @@ -111,6 +117,23 @@ export const useAddOrUpdateException = ({ }); } + if (bulkCloseIndex != null) { + const filter = getQueryFilter( + '', + 'kuery', + buildAlertStatusFilter('open'), + bulkCloseIndex, + prepareExceptionItemsForBulkClose(exceptionItemsToAddOrUpdate), + false + ); + await updateAlertStatus({ + query: { + query: filter, + }, + status: 'closed', + }); + } + await addOrUpdateItems(exceptionItemsToAddOrUpdate); if (isSubscribed) { From b7a6cff74d84afe51887830d4b2faf5aad57aa14 Mon Sep 17 00:00:00 2001 From: Candace Park <56409205+parkiino@users.noreply.github.com> Date: Tue, 14 Jul 2020 00:00:29 -0400 Subject: [PATCH 10/57] [Security Solution] Add 3rd level breadcrumb to admin page (#71275) [Endpoint Security] Add 3rd level (hosts / policies) breadcrumb to admin page --- .../security_solution/common/constants.ts | 2 +- .../cypress/integration/navigation.spec.ts | 4 +- .../cypress/screens/security_header.ts | 2 +- .../public/app/home/home_navigations.tsx | 6 +-- .../navigation/breadcrumbs/index.ts | 27 +++++++++++ .../components/navigation/index.test.tsx | 12 ++--- .../common/components/navigation/types.ts | 2 +- .../common/components/url_state/constants.ts | 2 +- .../common/components/url_state/helpers.ts | 2 + .../common/components/url_state/types.ts | 2 +- .../public/common/utils/route/types.ts | 7 ++- .../public/management/common/constants.ts | 10 ++--- .../public/management/common/routing.ts | 10 ++--- .../public/management/common/translations.ts | 15 +++++++ .../components/management_page_view.tsx | 16 +++---- .../view/details/host_details.tsx | 4 +- .../endpoint_hosts/view/details/index.tsx | 2 +- .../pages/endpoint_hosts/view/index.tsx | 10 ++--- .../public/management/pages/index.tsx | 45 +++++++++++++++++-- .../pages/policy/view/policy_details.test.tsx | 2 +- .../pages/policy/view/policy_details.tsx | 6 +-- .../pages/policy/view/policy_list.tsx | 4 +- .../public/management/types.ts | 6 +-- .../security_solution/public/plugin.tsx | 2 +- .../security_solution/server/plugin.ts | 2 +- 25 files changed, 145 insertions(+), 57 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/management/common/translations.ts diff --git a/x-pack/plugins/security_solution/common/constants.ts b/x-pack/plugins/security_solution/common/constants.ts index 4e9514feec74f1..516ee19dd3b03a 100644 --- a/x-pack/plugins/security_solution/common/constants.ts +++ b/x-pack/plugins/security_solution/common/constants.ts @@ -42,7 +42,7 @@ export enum SecurityPageName { network = 'network', timelines = 'timelines', case = 'case', - management = 'management', + administration = 'administration', } export const APP_OVERVIEW_PATH = `${APP_PATH}/overview`; diff --git a/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts b/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts index e4f0ec2c4828f1..792eee3660429b 100644 --- a/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/navigation.spec.ts @@ -7,7 +7,7 @@ import { CASES, DETECTIONS, HOSTS, - MANAGEMENT, + ADMINISTRATION, NETWORK, OVERVIEW, TIMELINES, @@ -73,7 +73,7 @@ describe('top-level navigation common to all pages in the Security app', () => { }); it('navigates to the Administration page', () => { - navigateFromHeaderTo(MANAGEMENT); + navigateFromHeaderTo(ADMINISTRATION); cy.url().should('include', ADMINISTRATION_URL); }); }); diff --git a/x-pack/plugins/security_solution/cypress/screens/security_header.ts b/x-pack/plugins/security_solution/cypress/screens/security_header.ts index 20fcae60415ae3..a337db7a9bfaa6 100644 --- a/x-pack/plugins/security_solution/cypress/screens/security_header.ts +++ b/x-pack/plugins/security_solution/cypress/screens/security_header.ts @@ -14,7 +14,7 @@ export const HOSTS = '[data-test-subj="navigation-hosts"]'; export const KQL_INPUT = '[data-test-subj="queryInput"]'; -export const MANAGEMENT = '[data-test-subj="navigation-management"]'; +export const ADMINISTRATION = '[data-test-subj="navigation-administration"]'; export const NETWORK = '[data-test-subj="navigation-network"]'; diff --git a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx index 543a4634ceecc7..9f0f5351d8a54e 100644 --- a/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx +++ b/x-pack/plugins/security_solution/public/app/home/home_navigations.tsx @@ -61,11 +61,11 @@ export const navTabs: SiemNavTab = { disabled: false, urlKey: 'case', }, - [SecurityPageName.management]: { - id: SecurityPageName.management, + [SecurityPageName.administration]: { + id: SecurityPageName.administration, name: i18n.ADMINISTRATION, href: APP_MANAGEMENT_PATH, disabled: false, - urlKey: SecurityPageName.management, + urlKey: SecurityPageName.administration, }, }; diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts index dc5324adbac7d3..845ef580ddbe20 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/breadcrumbs/index.ts @@ -15,12 +15,14 @@ import { getBreadcrumbs as getIPDetailsBreadcrumbs } from '../../../../network/p import { getBreadcrumbs as getCaseDetailsBreadcrumbs } from '../../../../cases/pages/utils'; import { getBreadcrumbs as getDetectionRulesBreadcrumbs } from '../../../../detections/pages/detection_engine/rules/utils'; import { getBreadcrumbs as getTimelinesBreadcrumbs } from '../../../../timelines/pages'; +import { getBreadcrumbs as getAdminBreadcrumbs } from '../../../../management/pages'; import { SecurityPageName } from '../../../../app/types'; import { RouteSpyState, HostRouteSpyState, NetworkRouteSpyState, TimelineRouteSpyState, + AdministrationRouteSpyState, } from '../../../utils/route/types'; import { getAppOverviewUrl } from '../../link_to'; @@ -61,6 +63,10 @@ const isCaseRoutes = (spyState: RouteSpyState): spyState is RouteSpyState => const isAlertsRoutes = (spyState: RouteSpyState) => spyState != null && spyState.pageName === SecurityPageName.detections; +const isAdminRoutes = (spyState: RouteSpyState): spyState is AdministrationRouteSpyState => + spyState != null && spyState.pageName === SecurityPageName.administration; + +// eslint-disable-next-line complexity export const getBreadcrumbsForRoute = ( object: RouteSpyState & TabNavigationProps, getUrlForApp: GetUrlForApp @@ -159,6 +165,27 @@ export const getBreadcrumbsForRoute = ( ), ]; } + + if (isAdminRoutes(spyState) && object.navTabs) { + const tempNav: SearchNavTab = { urlKey: 'administration', isDetailPage: false }; + let urlStateKeys = [getOr(tempNav, spyState.pageName, object.navTabs)]; + if (spyState.tabName != null) { + urlStateKeys = [...urlStateKeys, getOr(tempNav, spyState.tabName, object.navTabs)]; + } + + return [ + ...siemRootBreadcrumb, + ...getAdminBreadcrumbs( + spyState, + urlStateKeys.reduce( + (acc: string[], item: SearchNavTab) => [...acc, getSearch(item, object)], + [] + ), + getUrlForApp + ), + ]; + } + if ( spyState != null && object.navTabs && diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx index 229e2d2402298e..c60feb63241fb9 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/navigation/index.test.tsx @@ -106,12 +106,12 @@ describe('SIEM Navigation', () => { name: 'Cases', urlKey: 'case', }, - management: { + administration: { disabled: false, href: '/app/security/administration', - id: 'management', + id: 'administration', name: 'Administration', - urlKey: 'management', + urlKey: 'administration', }, hosts: { disabled: false, @@ -218,12 +218,12 @@ describe('SIEM Navigation', () => { name: 'Hosts', urlKey: 'host', }, - management: { + administration: { disabled: false, href: '/app/security/administration', - id: 'management', + id: 'administration', name: 'Administration', - urlKey: 'management', + urlKey: 'administration', }, network: { disabled: false, diff --git a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts index 0489ebba738c8e..c17abaad525a2c 100644 --- a/x-pack/plugins/security_solution/public/common/components/navigation/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/navigation/types.ts @@ -48,7 +48,7 @@ export type SiemNavTabKey = | SecurityPageName.detections | SecurityPageName.timelines | SecurityPageName.case - | SecurityPageName.management; + | SecurityPageName.administration; export type SiemNavTab = Record; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts index 1faff2594ce804..5a4aec93dd9aaa 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/constants.ts @@ -30,4 +30,4 @@ export type UrlStateType = | 'network' | 'overview' | 'timeline' - | 'management'; + | 'administration'; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts index 6febf95aae01de..5e40cd00fa69ef 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/helpers.ts @@ -96,6 +96,8 @@ export const getUrlType = (pageName: string): UrlStateType => { return 'timeline'; } else if (pageName === SecurityPageName.case) { return 'case'; + } else if (pageName === SecurityPageName.administration) { + return 'administration'; } return 'overview'; }; diff --git a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts index 8881a82e5cd1c0..f383e181323854 100644 --- a/x-pack/plugins/security_solution/public/common/components/url_state/types.ts +++ b/x-pack/plugins/security_solution/public/common/components/url_state/types.ts @@ -46,7 +46,7 @@ export const URL_STATE_KEYS: Record = { CONSTANTS.timerange, CONSTANTS.timeline, ], - management: [], + administration: [], network: [ CONSTANTS.appQuery, CONSTANTS.filters, diff --git a/x-pack/plugins/security_solution/public/common/utils/route/types.ts b/x-pack/plugins/security_solution/public/common/utils/route/types.ts index 8656f20c929591..13eb03b07353d2 100644 --- a/x-pack/plugins/security_solution/public/common/utils/route/types.ts +++ b/x-pack/plugins/security_solution/public/common/utils/route/types.ts @@ -12,9 +12,10 @@ import { TimelineType } from '../../../../common/types/timeline'; import { HostsTableType } from '../../../hosts/store/model'; import { NetworkRouteType } from '../../../network/pages/navigation/types'; +import { AdministrationSubTab as AdministrationType } from '../../../management/types'; import { FlowTarget } from '../../../graphql/types'; -export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType; +export type SiemRouteType = HostsTableType | NetworkRouteType | TimelineType | AdministrationType; export interface RouteSpyState { pageName: string; detailName: string | undefined; @@ -38,6 +39,10 @@ export interface TimelineRouteSpyState extends RouteSpyState { tabName: TimelineType | undefined; } +export interface AdministrationRouteSpyState extends RouteSpyState { + tabName: AdministrationType | undefined; +} + export type RouteSpyAction = | { type: 'updateSearch'; diff --git a/x-pack/plugins/security_solution/public/management/common/constants.ts b/x-pack/plugins/security_solution/public/management/common/constants.ts index 4bc586bdee8a9e..b07c47a3980498 100644 --- a/x-pack/plugins/security_solution/public/management/common/constants.ts +++ b/x-pack/plugins/security_solution/public/management/common/constants.ts @@ -3,16 +3,16 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { ManagementStoreGlobalNamespace, ManagementSubTab } from '../types'; +import { ManagementStoreGlobalNamespace, AdministrationSubTab } from '../types'; import { APP_ID } from '../../../common/constants'; import { SecurityPageName } from '../../app/types'; // --[ ROUTING ]--------------------------------------------------------------------------- -export const MANAGEMENT_APP_ID = `${APP_ID}:${SecurityPageName.management}`; +export const MANAGEMENT_APP_ID = `${APP_ID}:${SecurityPageName.administration}`; export const MANAGEMENT_ROUTING_ROOT_PATH = ''; -export const MANAGEMENT_ROUTING_HOSTS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.hosts})`; -export const MANAGEMENT_ROUTING_POLICIES_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.policies})`; -export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${ManagementSubTab.policies})/:policyId`; +export const MANAGEMENT_ROUTING_HOSTS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.hosts})`; +export const MANAGEMENT_ROUTING_POLICIES_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.policies})`; +export const MANAGEMENT_ROUTING_POLICY_DETAILS_PATH = `${MANAGEMENT_ROUTING_ROOT_PATH}/:tabName(${AdministrationSubTab.policies})/:policyId`; // --[ STORE ]--------------------------------------------------------------------------- /** The SIEM global store namespace where the management state will be mounted */ 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 5add6b753a7a94..3636358ebe8422 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -14,7 +14,7 @@ import { MANAGEMENT_ROUTING_POLICIES_PATH, MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, } from './constants'; -import { ManagementSubTab } from '../types'; +import { AdministrationSubTab } from '../types'; import { appendSearch } from '../../common/components/link_to/helpers'; import { HostIndexUIQueryParams } from '../pages/endpoint_hosts/types'; @@ -47,7 +47,7 @@ export const getHostListPath = ( if (name === 'hostList') { return `${generatePath(MANAGEMENT_ROUTING_HOSTS_PATH, { - tabName: ManagementSubTab.hosts, + tabName: AdministrationSubTab.hosts, })}${appendSearch(`${urlQueryParams ? `${urlQueryParams}${urlSearch}` : urlSearch}`)}`; } return `${appendSearch(`${urlQueryParams ? `${urlQueryParams}${urlSearch}` : urlSearch}`)}`; @@ -65,17 +65,17 @@ export const getHostDetailsPath = ( const urlSearch = `${urlQueryParams && !isEmpty(search) ? '&' : ''}${search ?? ''}`; return `${generatePath(MANAGEMENT_ROUTING_HOSTS_PATH, { - tabName: ManagementSubTab.hosts, + tabName: AdministrationSubTab.hosts, })}${appendSearch(`${urlQueryParams ? `${urlQueryParams}${urlSearch}` : urlSearch}`)}`; }; export const getPoliciesPath = (search?: string) => `${generatePath(MANAGEMENT_ROUTING_POLICIES_PATH, { - tabName: ManagementSubTab.policies, + tabName: AdministrationSubTab.policies, })}${appendSearch(search)}`; export const getPolicyDetailPath = (policyId: string, search?: string) => `${generatePath(MANAGEMENT_ROUTING_POLICY_DETAILS_PATH, { - tabName: ManagementSubTab.policies, + tabName: AdministrationSubTab.policies, policyId, })}${appendSearch(search)}`; diff --git a/x-pack/plugins/security_solution/public/management/common/translations.ts b/x-pack/plugins/security_solution/public/management/common/translations.ts new file mode 100644 index 00000000000000..70ccf715eaa099 --- /dev/null +++ b/x-pack/plugins/security_solution/public/management/common/translations.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const HOSTS_TAB = i18n.translate('xpack.securitySolution.hostsTab', { + defaultMessage: 'Hosts', +}); + +export const POLICIES_TAB = i18n.translate('xpack.securitySolution.policiesTab', { + defaultMessage: 'Policies', +}); diff --git a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx index 8495628709d2ae..42341b524362df 100644 --- a/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx +++ b/x-pack/plugins/security_solution/public/management/components/management_page_view.tsx @@ -8,15 +8,15 @@ import React, { memo, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { useParams } from 'react-router-dom'; import { PageView, PageViewProps } from '../../common/components/endpoint/page_view'; -import { ManagementSubTab } from '../types'; +import { AdministrationSubTab } from '../types'; import { SecurityPageName } from '../../app/types'; import { useFormatUrl } from '../../common/components/link_to'; import { getHostListPath, getPoliciesPath } from '../common/routing'; import { useNavigateByRouterEventHandler } from '../../common/hooks/endpoint/use_navigate_by_router_event_handler'; export const ManagementPageView = memo>((options) => { - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); - const { tabName } = useParams<{ tabName: ManagementSubTab }>(); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); + const { tabName } = useParams<{ tabName: AdministrationSubTab }>(); const goToEndpoint = useNavigateByRouterEventHandler( getHostListPath({ name: 'hostList' }, search) @@ -30,11 +30,11 @@ export const ManagementPageView = memo>((options) => } return [ { - name: i18n.translate('xpack.securitySolution.managementTabs.endpoints', { + name: i18n.translate('xpack.securitySolution.managementTabs.hosts', { defaultMessage: 'Hosts', }), - id: ManagementSubTab.hosts, - isSelected: tabName === ManagementSubTab.hosts, + id: AdministrationSubTab.hosts, + isSelected: tabName === AdministrationSubTab.hosts, href: formatUrl(getHostListPath({ name: 'hostList' })), onClick: goToEndpoint, }, @@ -42,8 +42,8 @@ export const ManagementPageView = memo>((options) => name: i18n.translate('xpack.securitySolution.managementTabs.policies', { defaultMessage: 'Policies', }), - id: ManagementSubTab.policies, - isSelected: tabName === ManagementSubTab.policies, + id: AdministrationSubTab.policies, + isSelected: tabName === AdministrationSubTab.policies, href: formatUrl(getPoliciesPath()), onClick: goToPolicies, }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx index 10ea271139e498..62efa621e6e3b9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/host_details.tsx @@ -61,7 +61,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { const policyStatus = useHostSelector( policyResponseStatus ) as keyof typeof POLICY_STATUS_TO_HEALTH_COLOR; - const { formatUrl } = useFormatUrl(SecurityPageName.management); + const { formatUrl } = useFormatUrl(SecurityPageName.administration); const detailsResultsUpper = useMemo(() => { return [ @@ -106,7 +106,7 @@ export const HostDetails = memo(({ details }: { details: HostMetadata }) => { path: agentDetailsWithFlyoutPath, state: { onDoneNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostDetailsPath({ name: 'hostDetails', selected_host: details.host.id }), }, diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx index e29d796325bd69..71b38853085581 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/details/index.tsx @@ -118,7 +118,7 @@ const PolicyResponseFlyoutPanel = memo<{ const responseAttentionCount = useHostSelector(policyResponseFailedOrWarningActionCount); const loading = useHostSelector(policyResponseLoading); const error = useHostSelector(policyResponseError); - const { formatUrl } = useFormatUrl(SecurityPageName.management); + const { formatUrl } = useFormatUrl(SecurityPageName.administration); const [detailsUri, detailsRoutePath] = useMemo( () => [ formatUrl( diff --git a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx index 6c6ab3930d7abe..c5d47e87c3e1be 100644 --- a/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/endpoint_hosts/view/index.tsx @@ -89,7 +89,7 @@ export const HostList = () => { policyItemsLoading, endpointPackageVersion, } = useHostSelector(selector); - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); const dispatch = useDispatch<(a: HostAction) => void>(); @@ -127,12 +127,12 @@ export const HostList = () => { }`, state: { onCancelNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostListPath({ name: 'hostList' }) }, ], onCancelUrl: formatUrl(getHostListPath({ name: 'hostList' })), onSaveNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostListPath({ name: 'hostList' }) }, ], }, @@ -145,7 +145,7 @@ export const HostList = () => { path: `#/configs/${selectedPolicyId}?openEnrollmentFlyout=true`, state: { onDoneNavigateTo: [ - 'securitySolution:management', + 'securitySolution:administration', { path: getHostListPath({ name: 'hostList' }) }, ], }, @@ -422,7 +422,7 @@ export const HostList = () => { )} {renderTableOrEmptyState} - + ); }; diff --git a/x-pack/plugins/security_solution/public/management/pages/index.tsx b/x-pack/plugins/security_solution/public/management/pages/index.tsx index 30800234ab24c3..3e1c0743fb4f1f 100644 --- a/x-pack/plugins/security_solution/public/management/pages/index.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/index.tsx @@ -4,9 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ +import { isEmpty } from 'lodash/fp'; import React, { memo } from 'react'; import { useHistory, Route, Switch } from 'react-router-dom'; +import { ChromeBreadcrumb } from 'kibana/public'; import { EuiText, EuiEmptyPrompt } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { PolicyContainer } from './policy'; @@ -18,10 +20,47 @@ import { import { NotFoundPage } from '../../app/404'; import { HostsContainer } from './endpoint_hosts'; import { getHostListPath } from '../common/routing'; +import { APP_ID, SecurityPageName } from '../../../common/constants'; +import { GetUrlForApp } from '../../common/components/navigation/types'; +import { AdministrationRouteSpyState } from '../../common/utils/route/types'; +import { ADMINISTRATION } from '../../app/home/translations'; +import { AdministrationSubTab } from '../types'; +import { HOSTS_TAB, POLICIES_TAB } from '../common/translations'; import { SpyRoute } from '../../common/utils/route/spy_routes'; -import { SecurityPageName } from '../../app/types'; import { useIngestEnabledCheck } from '../../common/hooks/endpoint/ingest_enabled'; +const TabNameMappedToI18nKey: Record = { + [AdministrationSubTab.hosts]: HOSTS_TAB, + [AdministrationSubTab.policies]: POLICIES_TAB, +}; + +export const getBreadcrumbs = ( + params: AdministrationRouteSpyState, + search: string[], + getUrlForApp: GetUrlForApp +): ChromeBreadcrumb[] => { + let breadcrumb = [ + { + text: ADMINISTRATION, + href: getUrlForApp(`${APP_ID}:${SecurityPageName.administration}`, { + path: !isEmpty(search[0]) ? search[0] : '', + }), + }, + ]; + + const tabName = params?.tabName; + if (!tabName) return breadcrumb; + + breadcrumb = [ + ...breadcrumb, + { + text: TabNameMappedToI18nKey[tabName], + href: '', + }, + ]; + return breadcrumb; +}; + const NoPermissions = memo(() => { return ( <> @@ -40,14 +79,14 @@ const NoPermissions = memo(() => {

} /> - + ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx index ca4d0929f7a7a4..8612b15f898572 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.test.tsx @@ -172,7 +172,7 @@ describe('Policy Details', () => { cancelbutton.simulate('click', { button: 0 }); const navigateToAppMockedCalls = coreStart.application.navigateToApp.mock.calls; expect(navigateToAppMockedCalls[navigateToAppMockedCalls.length - 1]).toEqual([ - 'securitySolution:management', + 'securitySolution:administration', { path: policyListPathUrl }, ]); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx index b5861b68a0756c..8fbc167670b41c 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_details.tsx @@ -55,7 +55,7 @@ export const PolicyDetails = React.memo(() => { application: { navigateToApp }, }, } = useKibana(); - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); const { state: locationRouteState } = useLocation(); // Store values @@ -149,7 +149,7 @@ export const PolicyDetails = React.memo(() => { {policyApiError?.message} ) : null} - + ); } @@ -251,7 +251,7 @@ export const PolicyDetails = React.memo(() => { - + ); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx index 8a77264c354ad4..8dbfbeeb5d8d62 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_list.tsx @@ -127,7 +127,7 @@ export const PolicyList = React.memo(() => { const { services, notifications } = useKibana(); const history = useHistory(); const location = useLocation(); - const { formatUrl, search } = useFormatUrl(SecurityPageName.management); + const { formatUrl, search } = useFormatUrl(SecurityPageName.administration); const [showDelete, setShowDelete] = useState(false); const [policyIdToDelete, setPolicyIdToDelete] = useState(''); @@ -477,7 +477,7 @@ export const PolicyList = React.memo(() => { handleTableChange, paginationSetup, ])} - + ); diff --git a/x-pack/plugins/security_solution/public/management/types.ts b/x-pack/plugins/security_solution/public/management/types.ts index cb21a236ddd7e6..86959caaba4f4a 100644 --- a/x-pack/plugins/security_solution/public/management/types.ts +++ b/x-pack/plugins/security_solution/public/management/types.ts @@ -24,7 +24,7 @@ export type ManagementState = CombinedState<{ /** * The management list of sub-tabs. Changes to these will impact the Router routes. */ -export enum ManagementSubTab { +export enum AdministrationSubTab { hosts = 'hosts', policies = 'policy', } @@ -33,8 +33,8 @@ export enum ManagementSubTab { * The URL route params for the Management Policy List section */ export interface ManagementRoutePolicyListParams { - pageName: SecurityPageName.management; - tabName: ManagementSubTab.policies; + pageName: SecurityPageName.administration; + tabName: AdministrationSubTab.policies; } /** diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 62328bd7677488..98ea2efe8721ec 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -281,7 +281,7 @@ export class Plugin implements IPlugin { From 24d29a31b8ee8d6eaa05cbd2c255350ef8b47148 Mon Sep 17 00:00:00 2001 From: Matthias Wilhelm Date: Tue, 14 Jul 2020 07:43:02 +0200 Subject: [PATCH 11/57] [Discover] Add caused_by.type and caused_by.reason to error toast modal (#70404) --- .../notifications/toasts/error_toast.tsx | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/core/public/notifications/toasts/error_toast.tsx b/src/core/public/notifications/toasts/error_toast.tsx index 6b53719839b0f8..df8214ce771afb 100644 --- a/src/core/public/notifications/toasts/error_toast.tsx +++ b/src/core/public/notifications/toasts/error_toast.tsx @@ -31,8 +31,7 @@ import { } from '@elastic/eui'; import { EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; - -import { OverlayStart } from '../../overlays'; +import { OverlayStart } from 'kibana/public'; import { I18nStart } from '../../i18n'; interface ErrorToastProps { @@ -43,6 +42,17 @@ interface ErrorToastProps { i18nContext: () => I18nStart['Context']; } +interface RequestError extends Error { + body?: { attributes?: { error: { caused_by: { type: string; reason: string } } } }; +} + +const isRequestError = (e: Error | RequestError): e is RequestError => { + if ('body' in e) { + return e.body?.attributes?.error?.caused_by !== undefined; + } + return false; +}; + /** * This should instead be replaced by the overlay service once it's available. * This does not use React portals so that if the parent toast times out, this modal @@ -56,6 +66,17 @@ function showErrorDialog({ i18nContext, }: Pick) { const I18nContext = i18nContext(); + let text = ''; + + if (isRequestError(error)) { + text += `${error?.body?.attributes?.error?.caused_by.type}\n`; + text += `${error?.body?.attributes?.error?.caused_by.reason}\n\n`; + } + + if (error.stack) { + text += error.stack; + } + const modal = openModal( mount( @@ -65,11 +86,11 @@ function showErrorDialog({ - {error.stack && ( + {text && ( - {error.stack} + {text} )} From 169397cec84ade939eafd540cb45ffb79de12f01 Mon Sep 17 00:00:00 2001 From: Oliver Gupte Date: Mon, 13 Jul 2020 23:10:02 -0700 Subject: [PATCH 12/57] [APM] Bug fixes from ML integration testing (#71564) * fixes bug where the anomaly detection setup link was showing alert incorrectly, adds unit tests * Fixes typo in getMlBucketSize query, uses terminate_after * Improve readbility of helper function to show alerts and unit tests --- .../apm/AnomalyDetectionSetupLink.test.tsx | 43 +++++++++++++++++++ .../Links/apm/AnomalyDetectionSetupLink.tsx | 19 +++++--- .../get_anomaly_data/get_ml_bucket_size.ts | 2 +- 3 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx new file mode 100644 index 00000000000000..268d8bd7ea8239 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.test.tsx @@ -0,0 +1,43 @@ +/* + * 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 { showAlert } from './AnomalyDetectionSetupLink'; + +describe('#showAlert', () => { + describe('when an environment is selected', () => { + it('should return true when there are no jobs', () => { + const result = showAlert([], 'testing'); + expect(result).toBe(true); + }); + it('should return true when environment is not included in the jobs', () => { + const result = showAlert( + [{ environment: 'staging' }, { environment: 'production' }], + 'testing' + ); + expect(result).toBe(true); + }); + it('should return false when environment is included in the jobs', () => { + const result = showAlert( + [{ environment: 'staging' }, { environment: 'production' }], + 'staging' + ); + expect(result).toBe(false); + }); + }); + describe('there is no environment selected (All)', () => { + it('should return true when there are no jobs', () => { + const result = showAlert([], undefined); + expect(result).toBe(true); + }); + it('should return false when there are any number of jobs', () => { + const result = showAlert( + [{ environment: 'staging' }, { environment: 'production' }], + undefined + ); + expect(result).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx index 88d15239b8fba9..6f3a5df480d7e7 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/AnomalyDetectionSetupLink.tsx @@ -23,16 +23,12 @@ export function AnomalyDetectionSetupLink() { ); const isFetchSuccess = status === FETCH_STATUS.SUCCESS; - // Show alert if there are no jobs OR if no job matches the current environment - const showAlert = - isFetchSuccess && !data.jobs.some((job) => environment === job.environment); - return ( {ANOMALY_DETECTION_LINK_LABEL} - {showAlert && ( + {isFetchSuccess && showAlert(data.jobs, environment) && ( @@ -61,3 +57,16 @@ const ANOMALY_DETECTION_LINK_LABEL = i18n.translate( 'xpack.apm.anomalyDetectionSetup.linkLabel', { defaultMessage: `Anomaly detection` } ); + +export function showAlert( + jobs: Array<{ environment: string }> = [], + environment: string | undefined +) { + return ( + // No job exists, or + jobs.length === 0 || + // no job exists for the selected environment + (environment !== undefined && + jobs.every((job) => environment !== job.environment)) + ); +} diff --git a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts index 2f5e703251c03a..154821b261fd19 100644 --- a/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts +++ b/x-pack/plugins/apm/server/lib/transactions/charts/get_anomaly_data/get_ml_bucket_size.ts @@ -31,7 +31,7 @@ export async function getMlBucketSize({ body: { _source: 'bucket_span', size: 1, - terminateAfter: 1, + terminate_after: 1, query: { bool: { filter: [ From 0f143a38c6d1f93c3beb263f2d7b3959bca2ceaa Mon Sep 17 00:00:00 2001 From: Kevin Qualters <56408403+kqualters-elastic@users.noreply.github.com> Date: Tue, 14 Jul 2020 03:39:39 -0400 Subject: [PATCH 13/57] [Security Solution] Add hook for reading/writing resolver query params (#70809) * Move resolver query param logic into shared hook * Store document location in state * Rename documentLocation to resolverComponentInstanceID * Use undefined for initial resolverComponentID value * Update type for initial state of component id --- .../public/resolver/store/data/action.ts | 1 + .../public/resolver/store/data/reducer.ts | 2 + .../resolver/store/data/selectors.test.ts | 21 ++++-- .../public/resolver/store/data/selectors.ts | 7 ++ .../public/resolver/store/selectors.ts | 5 ++ .../public/resolver/types.ts | 1 + .../public/resolver/view/index.tsx | 12 +++- .../public/resolver/view/map.tsx | 8 ++- .../public/resolver/view/panel.tsx | 43 ++----------- .../view/panels/panel_content_utilities.tsx | 4 +- .../resolver/view/process_event_dot.tsx | 35 +--------- .../view/use_resolver_query_params.ts | 64 +++++++++++++++++++ .../view/use_state_syncing_actions.ts | 6 +- .../components/graph_overlay/index.tsx | 5 +- 14 files changed, 131 insertions(+), 83 deletions(-) create mode 100644 x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts index 0d2a6936b4873d..b6edf68aa7dc28 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/action.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/action.ts @@ -75,6 +75,7 @@ interface AppReceivedNewExternalProperties { * the `_id` of an ES document. This defines the origin of the Resolver graph. */ databaseDocumentID?: string; + resolverComponentInstanceID: string; }; } diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts index 19b743374b8ed0..c43182ddbf835f 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/reducer.ts @@ -11,6 +11,7 @@ import { ResolverAction } from '../actions'; const initialState: DataState = { relatedEvents: new Map(), relatedEventsReady: new Map(), + resolverComponentInstanceID: undefined, }; export const dataReducer: Reducer = (state = initialState, action) => { @@ -18,6 +19,7 @@ export const dataReducer: Reducer = (state = initialS const nextState: DataState = { ...state, databaseDocumentID: action.payload.databaseDocumentID, + resolverComponentInstanceID: action.payload.resolverComponentInstanceID, }; return nextState; } else if (action.type === 'appRequestedResolverData') { diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts index 630dfe555548f3..cf23596db61342 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.test.ts @@ -53,11 +53,12 @@ describe('data state', () => { describe('when there is a databaseDocumentID but no pending request', () => { const databaseDocumentID = 'databaseDocumentID'; + const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { actions = [ { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }, ]; }); @@ -104,11 +105,12 @@ describe('data state', () => { }); describe('when there is a pending request for the current databaseDocumentID', () => { const databaseDocumentID = 'databaseDocumentID'; + const resolverComponentInstanceID = 'resolverComponentInstanceID'; beforeEach(() => { actions = [ { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }, { type: 'appRequestedResolverData', @@ -160,12 +162,17 @@ describe('data state', () => { describe('when there is a pending request for a different databaseDocumentID than the current one', () => { const firstDatabaseDocumentID = 'first databaseDocumentID'; const secondDatabaseDocumentID = 'second databaseDocumentID'; + const resolverComponentInstanceID1 = 'resolverComponentInstanceID1'; + const resolverComponentInstanceID2 = 'resolverComponentInstanceID2'; beforeEach(() => { actions = [ // receive the document ID, this would cause the middleware to starts the request { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID: firstDatabaseDocumentID }, + payload: { + databaseDocumentID: firstDatabaseDocumentID, + resolverComponentInstanceID: resolverComponentInstanceID1, + }, }, // this happens when the middleware starts the request { @@ -175,7 +182,10 @@ describe('data state', () => { // receive a different databaseDocumentID. this should cause the middleware to abort the existing request and start a new one { type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID: secondDatabaseDocumentID }, + payload: { + databaseDocumentID: secondDatabaseDocumentID, + resolverComponentInstanceID: resolverComponentInstanceID2, + }, }, ]; }); @@ -188,6 +198,9 @@ describe('data state', () => { it('should need to abort the request for the databaseDocumentID', () => { expect(selectors.databaseDocumentIDToFetch(state())).toBe(secondDatabaseDocumentID); }); + it('should use the correct location for the second resolver', () => { + expect(selectors.resolverComponentInstanceID(state())).toBe(resolverComponentInstanceID2); + }); it('should not have an error, more children, or more ancestors.', () => { expect(viewAsAString(state())).toMatchInlineSnapshot(` "is loading: true diff --git a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts index 990b911e5dbd0e..9f425217a8d3ea 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/data/selectors.ts @@ -41,6 +41,13 @@ export function isLoading(state: DataState): boolean { return state.pendingRequestDatabaseDocumentID !== undefined; } +/** + * A string for uniquely identifying the instance of resolver within the app. + */ +export function resolverComponentInstanceID(state: DataState): string { + return state.resolverComponentInstanceID ? state.resolverComponentInstanceID : ''; +} + /** * If a request was made and it threw an error or returned a failure response code. */ diff --git a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts index 6e512cfe13f622..64921d214cc1b8 100644 --- a/x-pack/plugins/security_solution/public/resolver/store/selectors.ts +++ b/x-pack/plugins/security_solution/public/resolver/store/selectors.ts @@ -69,6 +69,11 @@ export const databaseDocumentIDToAbort = composeSelectors( dataSelectors.databaseDocumentIDToAbort ); +export const resolverComponentInstanceID = composeSelectors( + dataStateSelector, + dataSelectors.resolverComponentInstanceID +); + export const processAdjacencies = composeSelectors( dataStateSelector, dataSelectors.processAdjacencies diff --git a/x-pack/plugins/security_solution/public/resolver/types.ts b/x-pack/plugins/security_solution/public/resolver/types.ts index 2025762a0605ce..064634472bbbec 100644 --- a/x-pack/plugins/security_solution/public/resolver/types.ts +++ b/x-pack/plugins/security_solution/public/resolver/types.ts @@ -177,6 +177,7 @@ export interface DataState { * The id used for the pending request, if there is one. */ readonly pendingRequestDatabaseDocumentID?: string; + readonly resolverComponentInstanceID: string | undefined; /** * The parameters and response from the last successful request. diff --git a/x-pack/plugins/security_solution/public/resolver/view/index.tsx b/x-pack/plugins/security_solution/public/resolver/view/index.tsx index 205180a40d62a4..c1ffa42d02abbc 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/index.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/index.tsx @@ -18,6 +18,7 @@ import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; export const Resolver = React.memo(function ({ className, databaseDocumentID, + resolverComponentInstanceID, }: { /** * Used by `styled-components`. @@ -28,6 +29,11 @@ export const Resolver = React.memo(function ({ * Used as the origin of the Resolver graph. */ databaseDocumentID?: string; + /** + * A string literal describing where in the app resolver is located, + * used to prevent collisions in things like query params + */ + resolverComponentInstanceID: string; }) { const context = useKibana(); const store = useMemo(() => { @@ -40,7 +46,11 @@ export const Resolver = React.memo(function ({ */ return ( - + ); }); diff --git a/x-pack/plugins/security_solution/public/resolver/view/map.tsx b/x-pack/plugins/security_solution/public/resolver/view/map.tsx index 3fc62fc3182849..000bf23c5f49dd 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/map.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/map.tsx @@ -29,6 +29,7 @@ import { SideEffectContext } from './side_effect_context'; export const ResolverMap = React.memo(function ({ className, databaseDocumentID, + resolverComponentInstanceID, }: { /** * Used by `styled-components`. @@ -39,12 +40,17 @@ export const ResolverMap = React.memo(function ({ * Used as the origin of the Resolver graph. */ databaseDocumentID?: string; + /** + * A string literal describing where in the app resolver is located, + * used to prevent collisions in things like query params + */ + resolverComponentInstanceID: string; }) { /** * This is responsible for dispatching actions that include any external data. * `databaseDocumentID` */ - useStateSyncingActions({ databaseDocumentID }); + useStateSyncingActions({ databaseDocumentID, resolverComponentInstanceID }); const { timestamp } = useContext(SideEffectContext); const { processNodePositions, connectingEdgeLineSegments } = useSelector( diff --git a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx index f4fe4fe520c929..061531b82d9355 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panel.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panel.tsx @@ -4,11 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { memo, useCallback, useMemo, useContext, useLayoutEffect, useState } from 'react'; +import React, { memo, useMemo, useContext, useLayoutEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { useHistory, useLocation } from 'react-router-dom'; -// eslint-disable-next-line import/no-nodejs-modules -import querystring from 'querystring'; import { EuiPanel } from '@elastic/eui'; import { displayNameRecord } from './process_event_dot'; import * as selectors from '../store/selectors'; @@ -21,7 +18,7 @@ import { EventCountsForProcess } from './panels/panel_content_related_counts'; import { ProcessDetails } from './panels/panel_content_process_detail'; import { ProcessListWithCounts } from './panels/panel_content_process_list'; import { RelatedEventDetail } from './panels/panel_content_related_detail'; -import { CrumbInfo } from './panels/panel_content_utilities'; +import { useResolverQueryParams } from './use_resolver_query_params'; /** * The team decided to use this table to determine which breadcrumbs/view to display: @@ -39,14 +36,11 @@ import { CrumbInfo } from './panels/panel_content_utilities'; * @returns {JSX.Element} The "right" table content to show based on the query params as described above */ const PanelContent = memo(function PanelContent() { - const history = useHistory(); - const urlSearch = useLocation().search; const dispatch = useResolverDispatch(); const { timestamp } = useContext(SideEffectContext); - const queryParams: CrumbInfo = useMemo(() => { - return { crumbId: '', crumbEvent: '', ...querystring.parse(urlSearch.slice(1)) }; - }, [urlSearch]); + + const { pushToQueryParams, queryParams } = useResolverQueryParams(); const graphableProcesses = useSelector(selectors.graphableProcesses); const graphableProcessEntityIds = useMemo(() => { @@ -104,35 +98,6 @@ const PanelContent = memo(function PanelContent() { } }, [dispatch, uiSelectedEvent, paramsSelectedEvent, lastUpdatedProcess, timestamp]); - /** - * This updates the breadcrumb nav and the panel view. It's supplied to each - * panel content view to allow them to dispatch transitions to each other. - */ - const pushToQueryParams = useCallback( - (newCrumbs: CrumbInfo) => { - // Construct a new set of params from the current set (minus empty params) - // by assigning the new set of params provided in `newCrumbs` - const crumbsToPass = { - ...querystring.parse(urlSearch.slice(1)), - ...newCrumbs, - }; - - // If either was passed in as empty, remove it from the record - if (crumbsToPass.crumbId === '') { - delete crumbsToPass.crumbId; - } - if (crumbsToPass.crumbEvent === '') { - delete crumbsToPass.crumbEvent; - } - - const relativeURL = { search: querystring.stringify(crumbsToPass) }; - // We probably don't want to nuke the user's history with a huge - // trail of these, thus `.replace` instead of `.push` - return history.replace(relativeURL); - }, - [history, urlSearch] - ); - const relatedEventStats = useSelector(selectors.relatedEventsStats); const { crumbId, crumbEvent } = queryParams; const relatedStatsForIdFromParams: ResolverNodeStats | undefined = diff --git a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx index 374c4c94c77688..4dedafe55bb2ce 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/panels/panel_content_utilities.tsx @@ -27,8 +27,8 @@ const BetaHeader = styled(`header`)` * The two query parameters we read/write on to control which view the table presents: */ export interface CrumbInfo { - readonly crumbId: string; - readonly crumbEvent: string; + crumbId: string; + crumbEvent: string; } const ThemedBreadcrumbs = styled(EuiBreadcrumbs)<{ background: string; text: string }>` diff --git a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx index 6442735abc8cdd..17e7d3df429314 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/security_solution/public/resolver/view/process_event_dot.tsx @@ -10,9 +10,6 @@ import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { htmlIdGenerator, EuiButton, EuiI18nNumber, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { useHistory } from 'react-router-dom'; -// eslint-disable-next-line import/no-nodejs-modules -import querystring from 'querystring'; import { useSelector } from 'react-redux'; import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../models/vector2'; @@ -22,7 +19,7 @@ import { ResolverEvent, ResolverNodeStats } from '../../../common/endpoint/types import { useResolverDispatch } from './use_resolver_dispatch'; import * as eventModel from '../../../common/endpoint/models/event'; import * as selectors from '../store/selectors'; -import { CrumbInfo } from './panels/panel_content_utilities'; +import { useResolverQueryParams } from './use_resolver_query_params'; /** * A record of all known event types (in schema format) to translations @@ -403,35 +400,7 @@ const UnstyledProcessEventDot = React.memo( }); }, [dispatch, selfId]); - const history = useHistory(); - const urlSearch = history.location.search; - - /** - * This updates the breadcrumb nav, the table view - */ - const pushToQueryParams = useCallback( - (newCrumbs: CrumbInfo) => { - // Construct a new set of params from the current set (minus empty params) - // by assigning the new set of params provided in `newCrumbs` - const crumbsToPass = { - ...querystring.parse(urlSearch.slice(1)), - ...newCrumbs, - }; - - // If either was passed in as empty, remove it from the record - if (crumbsToPass.crumbId === '') { - delete crumbsToPass.crumbId; - } - if (crumbsToPass.crumbEvent === '') { - delete crumbsToPass.crumbEvent; - } - - const relativeURL = { search: querystring.stringify(crumbsToPass) }; - - return history.replace(relativeURL); - }, - [history, urlSearch] - ); + const { pushToQueryParams } = useResolverQueryParams(); const handleClick = useCallback(() => { if (animationTarget.current !== null) { diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts new file mode 100644 index 00000000000000..70baef5fa88ea6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/resolver/view/use_resolver_query_params.ts @@ -0,0 +1,64 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { useCallback, useMemo } from 'react'; +// eslint-disable-next-line import/no-nodejs-modules +import querystring from 'querystring'; +import { useSelector } from 'react-redux'; +import { useHistory, useLocation } from 'react-router-dom'; +import * as selectors from '../store/selectors'; +import { CrumbInfo } from './panels/panel_content_utilities'; + +export function useResolverQueryParams() { + /** + * This updates the breadcrumb nav and the panel view. It's supplied to each + * panel content view to allow them to dispatch transitions to each other. + */ + const history = useHistory(); + const urlSearch = useLocation().search; + const resolverComponentInstanceID = useSelector(selectors.resolverComponentInstanceID); + const uniqueCrumbIdKey: string = `${resolverComponentInstanceID}CrumbId`; + const uniqueCrumbEventKey: string = `${resolverComponentInstanceID}CrumbEvent`; + const pushToQueryParams = useCallback( + (newCrumbs: CrumbInfo) => { + // Construct a new set of params from the current set (minus empty params) + // by assigning the new set of params provided in `newCrumbs` + const crumbsToPass = { + ...querystring.parse(urlSearch.slice(1)), + [uniqueCrumbIdKey]: newCrumbs.crumbId, + [uniqueCrumbEventKey]: newCrumbs.crumbEvent, + }; + + // If either was passed in as empty, remove it from the record + if (newCrumbs.crumbId === '') { + delete crumbsToPass[uniqueCrumbIdKey]; + } + if (newCrumbs.crumbEvent === '') { + delete crumbsToPass[uniqueCrumbEventKey]; + } + + const relativeURL = { search: querystring.stringify(crumbsToPass) }; + // We probably don't want to nuke the user's history with a huge + // trail of these, thus `.replace` instead of `.push` + return history.replace(relativeURL); + }, + [history, urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey] + ); + const queryParams: CrumbInfo = useMemo(() => { + const parsed = querystring.parse(urlSearch.slice(1)); + const crumbEvent = parsed[uniqueCrumbEventKey]; + const crumbId = parsed[uniqueCrumbIdKey]; + return { + crumbEvent: Array.isArray(crumbEvent) ? crumbEvent[0] : crumbEvent, + crumbId: Array.isArray(crumbId) ? crumbId[0] : crumbId, + }; + }, [urlSearch, uniqueCrumbIdKey, uniqueCrumbEventKey]); + + return { + pushToQueryParams, + queryParams, + }; +} diff --git a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts index b8ea2049f5c49a..642a054e8c5191 100644 --- a/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts +++ b/x-pack/plugins/security_solution/public/resolver/view/use_state_syncing_actions.ts @@ -13,17 +13,19 @@ import { useResolverDispatch } from './use_resolver_dispatch'; */ export function useStateSyncingActions({ databaseDocumentID, + resolverComponentInstanceID, }: { /** * The `_id` of an event in ES. Used to determine the origin of the Resolver graph. */ databaseDocumentID?: string; + resolverComponentInstanceID: string; }) { const dispatch = useResolverDispatch(); useLayoutEffect(() => { dispatch({ type: 'appReceivedNewExternalProperties', - payload: { databaseDocumentID }, + payload: { databaseDocumentID, resolverComponentInstanceID }, }); - }, [dispatch, databaseDocumentID]); + }, [dispatch, databaseDocumentID, resolverComponentInstanceID]); } diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx index fd5e8bc2434f3a..0b5b51d6f1fb2b 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.tsx @@ -118,7 +118,10 @@ const GraphOverlayComponent = ({ - + Date: Tue, 14 Jul 2020 09:40:27 +0200 Subject: [PATCH 14/57] Fix ScopedHistory mock and adapt usages (#71404) * Fix mock and adapt usages * fix snapshots * add comment about forcecast * remove mock overrides --- .../public/application/scoped_history.mock.ts | 13 +++--- .../embeddable_state_transfer.test.ts | 42 ++++--------------- .../helpers/setup_environment.tsx | 7 ++-- .../account_management_app.test.ts | 4 +- .../access_agreement_app.test.ts | 4 +- .../logged_out/logged_out_app.test.ts | 4 +- .../authentication/login/login_app.test.ts | 4 +- .../authentication/logout/logout_app.test.ts | 4 +- .../overwritten_session_app.test.ts | 4 +- .../api_keys/api_keys_management_app.test.tsx | 3 +- .../edit_role_mapping_page.test.tsx | 3 +- .../role_mappings_grid_page.test.tsx | 2 +- .../role_mappings_management_app.test.tsx | 3 +- .../roles/edit_role/edit_role_page.test.tsx | 4 +- .../roles/roles_grid/roles_grid_page.test.tsx | 9 ++-- .../roles/roles_management_app.test.tsx | 4 +- .../users/edit_user/edit_user_page.test.tsx | 3 +- .../users/users_grid/users_grid_page.test.tsx | 2 +- .../users/users_management_app.test.tsx | 3 +- .../helpers/setup_environment.tsx | 7 ++-- .../edit_space/manage_space_page.test.tsx | 3 +- .../spaces_grid/spaces_grid_pages.test.tsx | 3 +- .../management/spaces_management_app.test.tsx | 3 +- .../actions_connectors_list.test.tsx | 11 +++-- .../components/alerts_list.test.tsx | 9 ++-- .../helpers/app_context.mock.tsx | 7 ++-- 26 files changed, 63 insertions(+), 102 deletions(-) diff --git a/src/core/public/application/scoped_history.mock.ts b/src/core/public/application/scoped_history.mock.ts index 41c72306a99f95..3b954313700f21 100644 --- a/src/core/public/application/scoped_history.mock.ts +++ b/src/core/public/application/scoped_history.mock.ts @@ -20,16 +20,16 @@ import { Location } from 'history'; import { ScopedHistory } from './scoped_history'; -type ScopedHistoryMock = jest.Mocked>; +export type ScopedHistoryMock = jest.Mocked; + const createMock = ({ pathname = '/', search = '', hash = '', key, state, - ...overrides -}: Partial = {}) => { - const mock: ScopedHistoryMock = { +}: Partial = {}) => { + const mock: jest.Mocked> = { block: jest.fn(), createHref: jest.fn(), createSubHistory: jest.fn(), @@ -39,7 +39,6 @@ const createMock = ({ listen: jest.fn(), push: jest.fn(), replace: jest.fn(), - ...overrides, action: 'PUSH', length: 1, location: { @@ -51,7 +50,9 @@ const createMock = ({ }, }; - return mock; + // jest.Mocked still expects private methods and properties to be present, even + // if not part of the public contract. + return mock as ScopedHistoryMock; }; export const scopedHistoryMock = { diff --git a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts index b7dd95ccba32ca..42adb9d770e8a5 100644 --- a/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts +++ b/src/plugins/embeddable/public/lib/state_transfer/embeddable_state_transfer.test.ts @@ -19,7 +19,7 @@ import { coreMock, scopedHistoryMock } from '../../../../../core/public/mocks'; import { EmbeddableStateTransfer } from '.'; -import { ApplicationStart, ScopedHistory } from '../../../../../core/public'; +import { ApplicationStart } from '../../../../../core/public'; function mockHistoryState(state: unknown) { return scopedHistoryMock.create({ state }); @@ -46,10 +46,7 @@ describe('embeddable state transfer', () => { it('can send an outgoing originating app state in append mode', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); await stateTransfer.navigateToEditor(destinationApp, { state: { originatingApp }, appendToExistingState: true, @@ -74,10 +71,7 @@ describe('embeddable state transfer', () => { it('can send an outgoing embeddable package state in append mode', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); await stateTransfer.navigateToWithEmbeddablePackage(destinationApp, { state: { type: 'coolestType', id: '150' }, appendToExistingState: true, @@ -90,40 +84,28 @@ describe('embeddable state transfer', () => { it('can fetch an incoming originating app state', async () => { const historyMock = mockHistoryState({ originatingApp: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEditorState(); expect(fetchedState).toEqual({ originatingApp: 'extremeSportsKibana' }); }); it('returns undefined with originating app state is not in the right shape', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEditorState(); expect(fetchedState).toBeUndefined(); }); it('can fetch an incoming embeddable package state', async () => { const historyMock = mockHistoryState({ type: 'skisEmbeddable', id: '123' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); expect(fetchedState).toEqual({ type: 'skisEmbeddable', id: '123' }); }); it('returns undefined when embeddable package is not in the right shape', async () => { const historyMock = mockHistoryState({ kibanaIsNowForSports: 'extremeSportsKibana' }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); const fetchedState = stateTransfer.getIncomingEmbeddablePackage(); expect(fetchedState).toBeUndefined(); }); @@ -135,10 +117,7 @@ describe('embeddable state transfer', () => { test1: 'test1', test2: 'test2', }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); stateTransfer.getIncomingEmbeddablePackage({ keysToRemoveAfterFetch: ['type', 'id'] }); expect(historyMock.replace).toHaveBeenCalledWith( expect.objectContaining({ state: { test1: 'test1', test2: 'test2' } }) @@ -152,10 +131,7 @@ describe('embeddable state transfer', () => { test1: 'test1', test2: 'test2', }); - stateTransfer = new EmbeddableStateTransfer( - application.navigateToApp, - (historyMock as unknown) as ScopedHistory - ); + stateTransfer = new EmbeddableStateTransfer(application.navigateToApp, historyMock); stateTransfer.getIncomingEmbeddablePackage(); expect(historyMock.location.state).toEqual({ type: 'skisEmbeddable', diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index fa8c4f82c1b68b..a5796c10f8d930 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -6,7 +6,6 @@ /* eslint-disable @kbn/eslint/no-restricted-paths */ import React from 'react'; import { LocationDescriptorObject } from 'history'; -import { ScopedHistory } from 'kibana/public'; import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public'; import { notificationServiceMock, @@ -35,10 +34,10 @@ const httpServiceSetupMock = new HttpService().setup({ fatalErrors: fatalErrorsServiceMock.createSetupContract(), }); -const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; -history.createHref = (location: LocationDescriptorObject) => { +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}?${location.search}`; -}; +}); const appServices = { breadcrumbs: breadcrumbService, diff --git a/x-pack/plugins/security/public/account_management/account_management_app.test.ts b/x-pack/plugins/security/public/account_management/account_management_app.test.ts index bac98d5639755e..37b97a84723104 100644 --- a/x-pack/plugins/security/public/account_management/account_management_app.test.ts +++ b/x-pack/plugins/security/public/account_management/account_management_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./account_management_page'); -import { AppMount, AppNavLinkStatus, ScopedHistory } from 'src/core/public'; +import { AppMount, AppNavLinkStatus } from 'src/core/public'; import { UserAPIClient } from '../management'; import { accountManagementApp } from './account_management_app'; @@ -54,7 +54,7 @@ describe('accountManagementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(coreStartMock.chrome.setBreadcrumbs).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts index add2db6a3c170d..0e262e9089842b 100644 --- a/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts +++ b/x-pack/plugins/security/public/authentication/access_agreement/access_agreement_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./access_agreement_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { accessAgreementApp } from './access_agreement_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -48,7 +48,7 @@ describe('accessAgreementApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./access_agreement_page').renderAccessAgreementPage; diff --git a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts index f0c18a3f1408e6..15d55136b405dc 100644 --- a/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logged_out/logged_out_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./logged_out_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { loggedOutApp } from './logged_out_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -46,7 +46,7 @@ describe('loggedOutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./logged_out_page').renderLoggedOutPage; diff --git a/x-pack/plugins/security/public/authentication/login/login_app.test.ts b/x-pack/plugins/security/public/authentication/login/login_app.test.ts index b7119d179b0b6c..a6e5a321ef6ec2 100644 --- a/x-pack/plugins/security/public/authentication/login/login_app.test.ts +++ b/x-pack/plugins/security/public/authentication/login/login_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./login_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { loginApp } from './login_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -51,7 +51,7 @@ describe('loginApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./login_page').renderLoginPage; diff --git a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts index 279500d14f2110..46b1083a2ed14a 100644 --- a/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts +++ b/x-pack/plugins/security/public/authentication/logout/logout_app.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { logoutApp } from './logout_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -52,7 +52,7 @@ describe('logoutApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(window.sessionStorage.clear).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts index 96e72ead229903..0eed1382c270b2 100644 --- a/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts +++ b/x-pack/plugins/security/public/authentication/overwritten_session/overwritten_session_app.test.ts @@ -6,7 +6,7 @@ jest.mock('./overwritten_session_page'); -import { AppMount, ScopedHistory } from 'src/core/public'; +import { AppMount } from 'src/core/public'; import { overwrittenSessionApp } from './overwritten_session_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -53,7 +53,7 @@ describe('overwrittenSessionApp', () => { element: containerMock, appBasePath: '', onAppLeave: jest.fn(), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); const mockRenderApp = jest.requireMock('./overwritten_session_page') diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index 5f07b14ee71ef3..30c5f8a361b424 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -7,7 +7,6 @@ jest.mock('./api_keys_grid', () => ({ APIKeysGridPage: (props: any) => `Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { apiKeysManagementApp } from './api_keys_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -37,7 +36,7 @@ describe('apiKeysManagementApp', () => { basePath: '/some-base-path', element: container, setBreadcrumbs, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }); expect(setBreadcrumbs).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx index b4e755507f8c5e..04dc9c6dfa9508 100644 --- a/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/edit_role_mapping/edit_role_mapping_page.test.tsx @@ -12,7 +12,6 @@ import { findTestSubject } from 'test_utils/find_test_subject'; // This is not required for the tests to pass, but it rather suppresses lengthy // warnings in the console which adds unnecessary noise to the test output. import 'test_utils/stub_web_worker'; -import { ScopedHistory } from 'kibana/public'; import { EditRoleMappingPage } from '.'; import { NoCompatibleRealms, SectionLoading, PermissionDenied } from '../components'; @@ -28,7 +27,7 @@ import { rolesAPIClientMock } from '../../roles/roles_api_client.mock'; import { RoleComboBox } from '../../role_combo_box'; describe('EditRoleMappingPage', () => { - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); let rolesAPI: PublicMethodsOf; beforeEach(() => { diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx index fb81ddb641e1f9..727d7bf56e9e20 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_grid/role_mappings_grid_page.test.tsx @@ -24,7 +24,7 @@ describe('RoleMappingsGridPage', () => { let coreStart: CoreStart; beforeEach(() => { - history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); coreStart = coreMock.createStart(); }); diff --git a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx index c95d78f90f51aa..e65310ba399ead 100644 --- a/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/role_mappings/role_mappings_management_app.test.tsx @@ -12,7 +12,6 @@ jest.mock('./edit_role_mapping', () => ({ EditRoleMappingPage: (props: any) => `Role Mapping Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { roleMappingsManagementApp } from './role_mappings_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -26,7 +25,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx index 43387d913e6fc5..f6fe2f394fd360 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.test.tsx @@ -8,7 +8,7 @@ import { ReactWrapper } from 'enzyme'; import React from 'react'; import { act } from '@testing-library/react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { Capabilities, ScopedHistory } from 'src/core/public'; +import { Capabilities } from 'src/core/public'; import { Feature } from '../../../../../features/public'; import { Role } from '../../../../common/model'; import { DocumentationLinksService } from '../documentation_links'; @@ -187,7 +187,7 @@ function getProps({ docLinks: new DocumentationLinksService(docLinks), fatalErrors, uiCapabilities: buildUICapabilities(canManageSpaces), - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), }; } diff --git a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx index d83d5ef3f6468a..005eebbfbf3bb3 100644 --- a/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_grid/roles_grid_page.test.tsx @@ -16,7 +16,6 @@ import { coreMock, scopedHistoryMock } from '../../../../../../../src/core/publi import { rolesAPIClientMock } from '../index.mock'; import { ReservedBadge, DisabledBadge } from '../../badges'; import { findTestSubject } from 'test_utils/find_test_subject'; -import { ScopedHistory } from 'kibana/public'; const mock403 = () => ({ body: { statusCode: 403 } }); @@ -42,12 +41,12 @@ const waitForRender = async ( describe('', () => { let apiClientMock: jest.Mocked>; - let history: ScopedHistory; + let history: ReturnType; beforeEach(() => { - history = (scopedHistoryMock.create({ - createHref: jest.fn((location) => location.pathname!), - }) as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); + history.createHref.mockImplementation((location) => location.pathname!); + apiClientMock = rolesAPIClientMock.create(); apiClientMock.getRoles.mockResolvedValue([ { diff --git a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx index e7f38c86b045e8..c45528399db99f 100644 --- a/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/roles/roles_management_app.test.tsx @@ -14,8 +14,6 @@ jest.mock('./edit_role', () => ({ EditRolePage: (props: any) => `Role Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; - import { rolesManagementApp } from './roles_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -40,7 +38,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx index 7ee33357b9af42..40ffc508f086b9 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx @@ -5,7 +5,6 @@ */ import { act } from '@testing-library/react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { EditUserPage } from './edit_user_page'; import React from 'react'; @@ -104,7 +103,7 @@ function expectMissingSaveButton(wrapper: ReactWrapper) { } describe('EditUserPage', () => { - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); it('allows reserved users to be viewed', async () => { const user = createUser('reserved_user'); diff --git a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx index edce7409e28d53..df8fe8cee76990 100644 --- a/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_grid/users_grid_page.test.tsx @@ -22,7 +22,7 @@ describe('UsersGridPage', () => { let coreStart: CoreStart; beforeEach(() => { - history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + history = scopedHistoryMock.create(); history.createHref = (location: LocationDescriptorObject) => { return `${location.pathname}${location.search ? '?' + location.search : ''}`; }; diff --git a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx index 98906f560e6cba..06bd2eff6aa1e5 100644 --- a/x-pack/plugins/security/public/management/users/users_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/users/users_management_app.test.tsx @@ -12,7 +12,6 @@ jest.mock('./edit_user', () => ({ EditUserPage: (props: any) => `User Edit Page: ${JSON.stringify(props)}`, })); -import { ScopedHistory } from 'src/core/public'; import { usersManagementApp } from './users_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../../src/core/public/mocks'; @@ -31,7 +30,7 @@ async function mountApp(basePath: string, pathname: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx index e3c0ab0be9bd23..2cfffb3572ddea 100644 --- a/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/snapshot_restore/__jest__/client_integration/helpers/setup_environment.tsx @@ -9,7 +9,6 @@ import axios from 'axios'; import axiosXhrAdapter from 'axios/lib/adapters/xhr'; import { i18n } from '@kbn/i18n'; import { LocationDescriptorObject } from 'history'; -import { ScopedHistory } from 'kibana/public'; import { coreMock, scopedHistoryMock } from 'src/core/public/mocks'; import { setUiMetricService, httpService } from '../../../public/application/services/http'; @@ -25,10 +24,10 @@ import { documentationLinksService } from '../../../public/application/services/ const mockHttpClient = axios.create({ adapter: axiosXhrAdapter }); -const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; -history.createHref = (location: LocationDescriptorObject) => { +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}?${location.search}`; -}; +}); export const services = { uiMetricService: new UiMetricService('snapshot_restore'), diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx index b0103800d4105d..b573848f0c84ae 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx @@ -7,7 +7,6 @@ import { EuiButton, EuiLink, EuiSwitch } from '@elastic/eui'; import { ReactWrapper } from 'enzyme'; import React from 'react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { ConfirmAlterActiveSpaceModal } from './confirm_alter_active_space_modal'; @@ -46,7 +45,7 @@ featuresStart.getFeatures.mockResolvedValue([ describe('ManageSpacePage', () => { const getUrlForApp = (appId: string) => appId; - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); it('allows a space to be created', async () => { const spacesManager = spacesManagerMock.create(); diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx index 1868823823a1ab..607570eedc7876 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_pages.test.tsx @@ -5,7 +5,6 @@ */ import React from 'react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl, shallowWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { SpaceAvatar } from '../../space_avatar'; import { spacesManagerMock } from '../../spaces_manager/mocks'; @@ -54,7 +53,7 @@ featuresStart.getFeatures.mockResolvedValue([ describe('SpacesGridPage', () => { const getUrlForApp = (appId: string) => appId; - const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; + const history = scopedHistoryMock.create(); it('renders as expected', () => { const httpStart = httpServiceMock.createStartContract(); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx index 834bfb73d8f467..1e8520a2617dd3 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx @@ -17,7 +17,6 @@ jest.mock('./edit_space', () => ({ }, })); -import { ScopedHistory } from 'src/core/public'; import { spacesManagementApp } from './spaces_management_app'; import { coreMock, scopedHistoryMock } from '../../../../../src/core/public/mocks'; @@ -58,7 +57,7 @@ async function mountApp(basePath: string, pathname: string, spaceId?: string) { basePath, element: container, setBreadcrumbs, - history: (scopedHistoryMock.create({ pathname }) as unknown) as ScopedHistory, + history: scopedHistoryMock.create({ pathname }), }); return { unmount, container, setBreadcrumbs }; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx index 40505ac3fe76c7..23a7223f9c21bd 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/actions_connectors_list/components/actions_connectors_list.test.tsx @@ -5,7 +5,6 @@ */ import * as React from 'react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; -import { ScopedHistory } from 'kibana/public'; import { ActionsConnectorsList } from './actions_connectors_list'; import { coreMock, scopedHistoryMock } from '../../../../../../../../src/core/public/mocks'; @@ -68,7 +67,7 @@ describe('actions_connectors_list component empty', () => { 'actions:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: {} as any, @@ -175,7 +174,7 @@ describe('actions_connectors_list component with items', () => { 'actions:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -263,7 +262,7 @@ describe('actions_connectors_list component empty with show only capability', () 'actions:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -352,7 +351,7 @@ describe('actions_connectors_list with show only capability', () => { 'actions:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -453,7 +452,7 @@ describe('actions_connectors_list component with disabled items', () => { 'actions:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index dc2c1f972a5db8..69b0856297bb5e 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import * as React from 'react'; -import { ScopedHistory } from 'kibana/public'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { coreMock, scopedHistoryMock } from '../../../../../../../../src/core/public/mocks'; @@ -103,7 +102,7 @@ describe('alerts_list component empty', () => { 'alerting:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, @@ -222,7 +221,7 @@ describe('alerts_list component with items', () => { 'alerting:delete': true, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, @@ -304,7 +303,7 @@ describe('alerts_list component empty with show only capability', () => { 'alerting:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: { get() { @@ -419,7 +418,7 @@ describe('alerts_list with show only capability', () => { 'alerting:delete': false, }, }, - history: (scopedHistoryMock.create() as unknown) as ScopedHistory, + history: scopedHistoryMock.create(), setBreadcrumbs: jest.fn(), actionTypeRegistry: actionTypeRegistry as any, alertTypeRegistry: alertTypeRegistry as any, diff --git a/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx b/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx index 142504ee163b76..3db3cf5c660116 100644 --- a/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx +++ b/x-pack/plugins/watcher/__jest__/client_integration/helpers/app_context.mock.tsx @@ -8,7 +8,6 @@ import React from 'react'; import { of } from 'rxjs'; import { ComponentType } from 'enzyme'; import { LocationDescriptorObject } from 'history'; -import { ScopedHistory } from 'src/core/public'; import { docLinksServiceMock, uiSettingsServiceMock, @@ -31,10 +30,10 @@ class MockTimeBuckets { } } -const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; -history.createHref = (location: LocationDescriptorObject) => { +const history = scopedHistoryMock.create(); +history.createHref.mockImplementation((location: LocationDescriptorObject) => { return `${location.pathname}${location.search ? '?' + location.search : ''}`; -}; +}); export const mockContextValue = { licenseStatus$: of({ valid: true }), From 35fc222bdced50cbd2143d675ddeacfdd4e4f431 Mon Sep 17 00:00:00 2001 From: Joe Reuter Date: Tue, 14 Jul 2020 09:43:39 +0200 Subject: [PATCH 15/57] adjust vislib bar opacity (#71421) --- .../vis_type_vislib/public/vislib/lib/layout/_layout.scss | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss b/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss index 6d96fa39e7c342..96c72bd5956d27 100644 --- a/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss +++ b/src/plugins/vis_type_vislib/public/vislib/lib/layout/_layout.scss @@ -304,11 +304,14 @@ .series > path, .series > rect { - fill-opacity: .8; stroke-opacity: 1; stroke-width: 0; } + .series > path { + fill-opacity: .8; + } + .blur_shape { // sass-lint:disable-block no-important opacity: .3 !important; From 831e427682303ee05be2c91c1de737184218e235 Mon Sep 17 00:00:00 2001 From: patrykkopycinski Date: Tue, 14 Jul 2020 10:57:51 +0200 Subject: [PATCH 16/57] [Security] Add Timeline improvements (#71506) --- .../cypress/tasks/timeline.ts | 3 ++ .../__snapshots__/providers.test.tsx.snap | 53 ++++++++++++++----- .../add_data_provider_popover.tsx | 33 ++++++++---- .../timeline/data_providers/providers.tsx | 27 ++++------ .../timelines/components/timeline/index.tsx | 4 +- 5 files changed, 78 insertions(+), 42 deletions(-) diff --git a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts index 37ce9094dc5941..761fd2c1e6a0bd 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/timeline.ts @@ -27,6 +27,8 @@ import { import { drag, drop } from '../tasks/common'; +export const hostExistsQuery = 'host.name: *'; + export const addDescriptionToTimeline = (description: string) => { cy.get(TIMELINE_DESCRIPTION).type(`${description}{enter}`); cy.get(DATE_PICKER_APPLY_BUTTON_TIMELINE).click().invoke('text').should('not.equal', 'Updating'); @@ -77,6 +79,7 @@ export const openTimelineSettings = () => { }; export const populateTimeline = () => { + executeTimelineKQL(hostExistsQuery); cy.get(SERVER_SIDE_EVENT_COUNT) .invoke('text') .then((strCount) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap index a227f39494b610..a86c99cbc094ae 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/__snapshots__/providers.test.tsx.snap @@ -9,10 +9,11 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - - + @@ -58,7 +59,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -106,7 +109,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -154,7 +159,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -202,7 +209,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -250,7 +259,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -298,7 +309,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -346,7 +359,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -394,7 +409,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -442,7 +459,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -490,7 +509,9 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` - + @@ -527,6 +548,10 @@ exports[`Providers rendering renders correctly against snapshot 1`] = ` ) + `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx index 8e1c02bad50a3f..71cf81c00dc09c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/add_data_provider_popover.tsx @@ -7,6 +7,7 @@ import React, { useCallback, useMemo, useState } from 'react'; import { EuiButton, + EuiButtonEmpty, EuiContextMenu, EuiText, EuiPopover, @@ -139,21 +140,33 @@ const AddDataProviderPopoverComponent: React.FC = ( [browserFields, handleDataProviderEdited, timelineId, timelineType] ); - const button = useMemo( - () => ( - { + if (timelineType === TimelineType.template) { + return ( + + {ADD_FIELD_LABEL} + + ); + } + + return ( + - {ADD_FIELD_LABEL} - - ), - [handleOpenPopover] - ); + {`+ ${ADD_FIELD_LABEL}`} + + ); + }, [handleOpenPopover, timelineType]); const content = useMemo(() => { if (timelineType === TimelineType.template) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx index c9dd906cee59b1..1142bbc214d74e 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/data_providers/providers.tsx @@ -82,10 +82,10 @@ const Parens = styled.span` `} `; -const AndOrBadgeContainer = styled.div` - width: 121px; - display: flex; - justify-content: flex-end; +const AndOrBadgeContainer = styled.div<{ hideBadge: boolean }>` + span { + visibility: ${({ hideBadge }) => (hideBadge ? 'hidden' : 'inherit')}; + } `; const LastAndOrBadgeInGroup = styled.div` @@ -113,10 +113,6 @@ const ParensContainer = styled(EuiFlexItem)` align-self: center; `; -const AddDataProviderContainer = styled.div` - padding-right: 9px; -`; - const getDataProviderValue = (dataProvider: DataProvidersAnd) => dataProvider.queryMatch.displayValue ?? dataProvider.queryMatch.value; @@ -152,15 +148,9 @@ export const Providers = React.memo( - {groupIndex === 0 ? ( - - - - ) : ( - - - - )} + + + {'('} @@ -300,6 +290,9 @@ export const Providers = React.memo( {')'} + {groupIndex === dataProviderGroups.length - 1 && ( + + )} ))} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx index 5265efc8109a48..c4d89fa29cb324 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/index.tsx @@ -266,7 +266,9 @@ const makeMapStateToProps = () => { // return events on empty search const kqlQueryExpression = - isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) ? ' ' : kqlQueryTimeline; + isEmpty(dataProviders) && isEmpty(kqlQueryTimeline) && timelineType === 'template' + ? ' ' + : kqlQueryTimeline; return { columns, dataProviders, From 3374b2d3b041143f87b8af1d35beea9d5f7bd93d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Tue, 14 Jul 2020 10:05:48 +0100 Subject: [PATCH 17/57] [Observability] Change appLink passing the date range (#71259) * changing apm appLink * changing apm appLink * removing title from api * adding absolute and relative times * addressing pr comments * addressing pr comments * addressing pr comments * fixing TS issues * addressing pr comments Co-authored-by: Elastic Machine --- x-pack/plugins/apm/public/plugin.ts | 8 +-- ....test.ts => apm_overview_fetchers.test.ts} | 43 +++++++------- ..._dashboard.ts => apm_overview_fetchers.ts} | 24 ++++---- .../get_service_count.ts | 0 .../get_transaction_coordinates.ts | 0 .../has_data.ts | 0 .../apm/server/routes/create_apm_api.ts | 10 ++-- ...dashboard.ts => observability_overview.ts} | 14 ++--- .../metrics_overview_fetchers.test.ts.snap | 3 +- .../public/metrics_overview_fetchers.test.ts | 12 +++- .../infra/public/metrics_overview_fetchers.ts | 27 ++++----- .../public/utils/logs_overview_fetchers.ts | 23 +++----- .../components/app/section/alerts/index.tsx | 14 +++-- .../components/app/section/apm/index.test.tsx | 15 +++-- .../components/app/section/apm/index.tsx | 34 +++++++---- .../app/section/apm/mock_data/apm.mock.ts | 2 - .../components/app/section/index.test.tsx | 4 +- .../public/components/app/section/index.tsx | 24 ++++---- .../components/app/section/logs/index.tsx | 34 +++++++---- .../components/app/section/metrics/index.tsx | 32 +++++++---- .../components/app/section/uptime/index.tsx | 36 ++++++++---- .../observability/public/data_handler.test.ts | 11 +++- .../public/pages/overview/index.tsx | 57 ++++++++++--------- .../public/pages/overview/mock/apm.mock.ts | 2 - .../public/pages/overview/mock/logs.mock.ts | 2 - .../pages/overview/mock/metrics.mock.ts | 2 - .../public/pages/overview/mock/uptime.mock.ts | 2 - .../typings/fetch_overview_data/index.ts | 8 +-- .../observability/public/utils/date.ts | 10 ++-- .../public/apps/uptime_overview_fetcher.ts | 23 ++++---- 30 files changed, 255 insertions(+), 221 deletions(-) rename x-pack/plugins/apm/public/services/rest/{observability.dashboard.test.ts => apm_overview_fetchers.test.ts} (78%) rename x-pack/plugins/apm/public/services/rest/{observability_dashboard.ts => apm_overview_fetchers.ts} (70%) rename x-pack/plugins/apm/server/lib/{observability_dashboard => observability_overview}/get_service_count.ts (100%) rename x-pack/plugins/apm/server/lib/{observability_dashboard => observability_overview}/get_transaction_coordinates.ts (100%) rename x-pack/plugins/apm/server/lib/{observability_dashboard => observability_overview}/has_data.ts (100%) rename x-pack/plugins/apm/server/routes/{observability_dashboard.ts => observability_overview.ts} (74%) diff --git a/x-pack/plugins/apm/public/plugin.ts b/x-pack/plugins/apm/public/plugin.ts index 6e3a29d9f3dbce..f264ae6cd98521 100644 --- a/x-pack/plugins/apm/public/plugin.ts +++ b/x-pack/plugins/apm/public/plugin.ts @@ -39,9 +39,9 @@ import { toggleAppLinkInNav } from './toggleAppLinkInNav'; import { setReadonlyBadge } from './updateBadge'; import { createStaticIndexPattern } from './services/rest/index_pattern'; import { - fetchLandingPageData, + fetchOverviewPageData, hasData, -} from './services/rest/observability_dashboard'; +} from './services/rest/apm_overview_fetchers'; export type ApmPluginSetup = void; export type ApmPluginStart = void; @@ -81,9 +81,7 @@ export class ApmPlugin implements Plugin { if (plugins.observability) { plugins.observability.dashboard.register({ appName: 'apm', - fetchData: async (params) => { - return fetchLandingPageData(params); - }, + fetchData: fetchOverviewPageData, hasData, }); } diff --git a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts similarity index 78% rename from x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts rename to x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts index fd407a8bf72ad3..8b3ed38e25319f 100644 --- a/x-pack/plugins/apm/public/services/rest/observability.dashboard.test.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.test.ts @@ -4,11 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fetchLandingPageData, hasData } from './observability_dashboard'; +import moment from 'moment'; +import { fetchOverviewPageData, hasData } from './apm_overview_fetchers'; import * as createCallApmApi from './createCallApmApi'; describe('Observability dashboard data', () => { const callApmApiMock = jest.spyOn(createCallApmApi, 'callApmApi'); + const params = { + absoluteTime: { + start: moment('2020-07-02T13:25:11.629Z').valueOf(), + end: moment('2020-07-09T14:25:11.629Z').valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, + bucketSize: '600s', + }; afterEach(() => { callApmApiMock.mockClear(); }); @@ -25,7 +37,7 @@ describe('Observability dashboard data', () => { }); }); - describe('fetchLandingPageData', () => { + describe('fetchOverviewPageData', () => { it('returns APM data with series and stats', async () => { callApmApiMock.mockImplementation(() => Promise.resolve({ @@ -37,14 +49,9 @@ describe('Observability dashboard data', () => { ], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', @@ -73,14 +80,9 @@ describe('Observability dashboard data', () => { transactionCoordinates: [], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', @@ -105,14 +107,9 @@ describe('Observability dashboard data', () => { transactionCoordinates: [{ x: 1 }, { x: 2 }, { x: 3 }], }) ); - const response = await fetchLandingPageData({ - startTime: '1', - endTime: '2', - bucketSize: '3', - }); + const response = await fetchOverviewPageData(params); expect(response).toEqual({ - title: 'APM', - appLink: '/app/apm', + appLink: '/app/apm#/services?rangeFrom=now-15m&rangeTo=now', stats: { services: { type: 'number', diff --git a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts similarity index 70% rename from x-pack/plugins/apm/public/services/rest/observability_dashboard.ts rename to x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts index 409cec8b9ce102..78f3a0a0aaa807 100644 --- a/x-pack/plugins/apm/public/services/rest/observability_dashboard.ts +++ b/x-pack/plugins/apm/public/services/rest/apm_overview_fetchers.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import { i18n } from '@kbn/i18n'; import { mean } from 'lodash'; import { ApmFetchDataResponse, @@ -12,23 +11,26 @@ import { } from '../../../../observability/public'; import { callApmApi } from './createCallApmApi'; -export const fetchLandingPageData = async ({ - startTime, - endTime, +export const fetchOverviewPageData = async ({ + absoluteTime, + relativeTime, bucketSize, }: FetchDataParams): Promise => { const data = await callApmApi({ - pathname: '/api/apm/observability_dashboard', - params: { query: { start: startTime, end: endTime, bucketSize } }, + pathname: '/api/apm/observability_overview', + params: { + query: { + start: new Date(absoluteTime.start).toISOString(), + end: new Date(absoluteTime.end).toISOString(), + bucketSize, + }, + }, }); const { serviceCount, transactionCoordinates } = data; return { - title: i18n.translate('xpack.apm.observabilityDashboard.title', { - defaultMessage: 'APM', - }), - appLink: '/app/apm', + appLink: `/app/apm#/services?rangeFrom=${relativeTime.start}&rangeTo=${relativeTime.end}`, stats: { services: { type: 'number', @@ -54,6 +56,6 @@ export const fetchLandingPageData = async ({ export async function hasData() { return await callApmApi({ - pathname: '/api/apm/observability_dashboard/has_data', + pathname: '/api/apm/observability_overview/has_data', }); } diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/get_service_count.ts rename to x-pack/plugins/apm/server/lib/observability_overview/get_service_count.ts diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts b/x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/get_transaction_coordinates.ts rename to x-pack/plugins/apm/server/lib/observability_overview/get_transaction_coordinates.ts diff --git a/x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts b/x-pack/plugins/apm/server/lib/observability_overview/has_data.ts similarity index 100% rename from x-pack/plugins/apm/server/lib/observability_dashboard/has_data.ts rename to x-pack/plugins/apm/server/lib/observability_overview/has_data.ts diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 513c44904683ea..0a4295fea39978 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -79,9 +79,9 @@ import { rumServicesRoute, } from './rum_client'; import { - observabilityDashboardHasDataRoute, - observabilityDashboardDataRoute, -} from './observability_dashboard'; + observabilityOverviewHasDataRoute, + observabilityOverviewRoute, +} from './observability_overview'; import { anomalyDetectionJobsRoute, createAnomalyDetectionJobsRoute, @@ -176,8 +176,8 @@ const createApmApi = () => { .add(rumServicesRoute) // Observability dashboard - .add(observabilityDashboardHasDataRoute) - .add(observabilityDashboardDataRoute) + .add(observabilityOverviewHasDataRoute) + .add(observabilityOverviewRoute) // Anomaly detection .add(anomalyDetectionJobsRoute) diff --git a/x-pack/plugins/apm/server/routes/observability_dashboard.ts b/x-pack/plugins/apm/server/routes/observability_overview.ts similarity index 74% rename from x-pack/plugins/apm/server/routes/observability_dashboard.ts rename to x-pack/plugins/apm/server/routes/observability_overview.ts index 10c74295fe3e42..d5bb3b49c2f4c5 100644 --- a/x-pack/plugins/apm/server/routes/observability_dashboard.ts +++ b/x-pack/plugins/apm/server/routes/observability_overview.ts @@ -5,22 +5,22 @@ */ import * as t from 'io-ts'; import { setupRequest } from '../lib/helpers/setup_request'; -import { hasData } from '../lib/observability_dashboard/has_data'; +import { getServiceCount } from '../lib/observability_overview/get_service_count'; +import { getTransactionCoordinates } from '../lib/observability_overview/get_transaction_coordinates'; +import { hasData } from '../lib/observability_overview/has_data'; import { createRoute } from './create_route'; import { rangeRt } from './default_api_types'; -import { getServiceCount } from '../lib/observability_dashboard/get_service_count'; -import { getTransactionCoordinates } from '../lib/observability_dashboard/get_transaction_coordinates'; -export const observabilityDashboardHasDataRoute = createRoute(() => ({ - path: '/api/apm/observability_dashboard/has_data', +export const observabilityOverviewHasDataRoute = createRoute(() => ({ + path: '/api/apm/observability_overview/has_data', handler: async ({ context, request }) => { const setup = await setupRequest(context, request); return await hasData({ setup }); }, })); -export const observabilityDashboardDataRoute = createRoute(() => ({ - path: '/api/apm/observability_dashboard', +export const observabilityOverviewRoute = createRoute(() => ({ + path: '/api/apm/observability_overview', params: { query: t.intersection([rangeRt, t.type({ bucketSize: t.string })]), }, diff --git a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap index 4680414493a2cd..d71e1feb575e49 100644 --- a/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap +++ b/x-pack/plugins/infra/public/__snapshots__/metrics_overview_fetchers.test.ts.snap @@ -2,7 +2,7 @@ exports[`Metrics UI Observability Homepage Functions createMetricsFetchData() should just work 1`] = ` Object { - "appLink": "/app/metrics", + "appLink": "/app/metrics/inventory?waffleTime=(currentTime:1593696311629,isAutoReloading:!f)", "series": Object { "inboundTraffic": Object { "coordinates": Array [ @@ -203,6 +203,5 @@ Object { "value": 3, }, }, - "title": "Metrics", } `; diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts index 24c51598ad2576..88bc426e9a0f76 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.test.ts @@ -53,12 +53,18 @@ describe('Metrics UI Observability Homepage Functions', () => { const { core, mockedGetStartServices } = setup(); core.http.post.mockResolvedValue(FAKE_SNAPSHOT_RESPONSE); const fetchData = createMetricsFetchData(mockedGetStartServices); - const endTime = moment(); + const endTime = moment('2020-07-02T13:25:11.629Z'); const startTime = endTime.clone().subtract(1, 'h'); const bucketSize = '300s'; const response = await fetchData({ - startTime: startTime.toISOString(), - endTime: endTime.toISOString(), + absoluteTime: { + start: startTime.valueOf(), + end: endTime.valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, bucketSize, }); expect(core.http.post).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts index 25b334d03c4f79..4eaf903e17608a 100644 --- a/x-pack/plugins/infra/public/metrics_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/metrics_overview_fetchers.ts @@ -4,15 +4,13 @@ * you may not use this file except in compliance with the Elastic License. */ -import moment from 'moment'; -import { sum, isFinite, isNumber } from 'lodash'; -import { i18n } from '@kbn/i18n'; -import { MetricsFetchDataResponse, FetchDataParams } from '../../observability/public'; +import { isFinite, isNumber, sum } from 'lodash'; +import { FetchDataParams, MetricsFetchDataResponse } from '../../observability/public'; import { - SnapshotRequest, SnapshotMetricInput, SnapshotNode, SnapshotNodeResponse, + SnapshotRequest, } from '../common/http_api/snapshot_api'; import { SnapshotMetricType } from '../common/inventory_models/types'; import { InfraClientCoreSetup } from './types'; @@ -77,13 +75,12 @@ export const combineNodeTimeseriesBy = ( export const createMetricsFetchData = ( getStartServices: InfraClientCoreSetup['getStartServices'] -) => async ({ - startTime, - endTime, - bucketSize, -}: FetchDataParams): Promise => { +) => async ({ absoluteTime, bucketSize }: FetchDataParams): Promise => { const [coreServices] = await getStartServices(); const { http } = coreServices; + + const { start, end } = absoluteTime; + const snapshotRequest: SnapshotRequest = { sourceId: 'default', metrics: ['cpu', 'memory', 'rx', 'tx'].map((type) => ({ type })) as SnapshotMetricInput[], @@ -91,8 +88,8 @@ export const createMetricsFetchData = ( nodeType: 'host', includeTimeseries: true, timerange: { - from: moment(startTime).valueOf(), - to: moment(endTime).valueOf(), + from: start, + to: end, interval: bucketSize, forceInterval: true, ignoreLookback: true, @@ -102,12 +99,8 @@ export const createMetricsFetchData = ( const results = await http.post('/api/metrics/snapshot', { body: JSON.stringify(snapshotRequest), }); - return { - title: i18n.translate('xpack.infra.observabilityHomepage.metrics.title', { - defaultMessage: 'Metrics', - }), - appLink: '/app/metrics', + appLink: `/app/metrics/inventory?waffleTime=(currentTime:${end},isAutoReloading:!f)`, stats: { hosts: { type: 'number', diff --git a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts index 5a0a996287959c..53f7e00a3354c2 100644 --- a/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts +++ b/x-pack/plugins/infra/public/utils/logs_overview_fetchers.ts @@ -5,18 +5,17 @@ */ import { encode } from 'rison-node'; -import { i18n } from '@kbn/i18n'; import { SearchResponse } from 'src/plugins/data/public'; -import { DEFAULT_SOURCE_ID } from '../../common/constants'; -import { InfraClientCoreSetup, InfraClientStartDeps } from '../types'; import { FetchData, - LogsFetchDataResponse, - HasData, FetchDataParams, + HasData, + LogsFetchDataResponse, } from '../../../observability/public'; +import { DEFAULT_SOURCE_ID } from '../../common/constants'; import { callFetchLogSourceConfigurationAPI } from '../containers/logs/log_source/api/fetch_log_source_configuration'; import { callFetchLogSourceStatusAPI } from '../containers/logs/log_source/api/fetch_log_source_status'; +import { InfraClientCoreSetup, InfraClientStartDeps } from '../types'; interface StatsAggregation { buckets: Array<{ key: string; doc_count: number }>; @@ -69,15 +68,11 @@ export function getLogsOverviewDataFetcher( data ); - const timeSpanInMinutes = - (Date.parse(params.endTime).valueOf() - Date.parse(params.startTime).valueOf()) / (1000 * 60); + const timeSpanInMinutes = (params.absoluteTime.end - params.absoluteTime.start) / (1000 * 60); return { - title: i18n.translate('xpack.infra.logs.logOverview.logOverviewTitle', { - defaultMessage: 'Logs', - }), - appLink: `/app/logs/stream?logPosition=(end:${encode(params.endTime)},start:${encode( - params.startTime + appLink: `/app/logs/stream?logPosition=(end:${encode(params.relativeTime.end)},start:${encode( + params.relativeTime.start )})`, stats: normalizeStats(stats, timeSpanInMinutes), series: normalizeSeries(series), @@ -122,8 +117,8 @@ function buildLogOverviewQuery(logParams: LogParams, params: FetchDataParams) { return { range: { [logParams.timestampField]: { - gt: params.startTime, - lte: params.endTime, + gt: new Date(params.absoluteTime.start).toISOString(), + lte: new Date(params.absoluteTime.end).toISOString(), format: 'strict_date_optional_time', }, }, diff --git a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx index 4c80195d33acea..c0dc67b3373b17 100644 --- a/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/alerts/index.tsx @@ -44,12 +44,16 @@ export const AlertsSection = ({ alerts }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx index d4b8236e0ef496..7b9d7276dd1c56 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.test.tsx @@ -8,6 +8,7 @@ import * as fetcherHook from '../../../../hooks/use_fetcher'; import { render } from '../../../../utils/test_helper'; import { APMSection } from './'; import { response } from './mock_data/apm.mock'; +import moment from 'moment'; describe('APMSection', () => { it('renders with transaction series and stats', () => { @@ -18,8 +19,11 @@ describe('APMSection', () => { }); const { getByText, queryAllByTestId } = render( ); @@ -38,8 +42,11 @@ describe('APMSection', () => { }); const { getByText, queryAllByText, getByTestId } = render( ); diff --git a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx index 697d4adfa0b754..dce80ed3244568 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/apm/index.tsx @@ -21,8 +21,8 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -30,20 +30,25 @@ function formatTpm(value?: number) { return numeral(value).format('0.00a'); } -export const APMSection = ({ startTime, endTime, bucketSize }: Props) => { +export const APMSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('apm')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('apm')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); - const { title = 'APM', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); const formatter = niceTimeFormatter([min, max]); @@ -53,8 +58,15 @@ export const APMSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts b/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts index 5857021b1537f2..edc236c714d32c 100644 --- a/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts +++ b/x-pack/plugins/observability/public/components/app/section/apm/mock_data/apm.mock.ts @@ -7,8 +7,6 @@ import { ApmFetchDataResponse } from '../../../../../typings'; export const response: ApmFetchDataResponse = { - title: 'APM', - appLink: '/app/apm', stats: { services: { value: 11, type: 'number' }, diff --git a/x-pack/plugins/observability/public/components/app/section/index.test.tsx b/x-pack/plugins/observability/public/components/app/section/index.test.tsx index 49cb175d0c0945..708a5e468dc7c3 100644 --- a/x-pack/plugins/observability/public/components/app/section/index.test.tsx +++ b/x-pack/plugins/observability/public/components/app/section/index.test.tsx @@ -20,13 +20,13 @@ describe('SectionContainer', () => { }); it('renders section with app link', () => { const component = render( - +
I am a very nice component
); expect(component.getByText('I am a very nice component')).toBeInTheDocument(); expect(component.getByText('Foo')).toBeInTheDocument(); - expect(component.getByText('View in app')).toBeInTheDocument(); + expect(component.getByText('foo')).toBeInTheDocument(); }); it('renders section with error', () => { const component = render( diff --git a/x-pack/plugins/observability/public/components/app/section/index.tsx b/x-pack/plugins/observability/public/components/app/section/index.tsx index 3556e8c01ab30c..9ba524259ea1c8 100644 --- a/x-pack/plugins/observability/public/components/app/section/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/index.tsx @@ -4,21 +4,23 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiAccordion, EuiLink, EuiPanel, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; import React from 'react'; import { ErrorPanel } from './error_panel'; import { usePluginContext } from '../../../hooks/use_plugin_context'; +interface AppLink { + label: string; + href?: string; +} + interface Props { title: string; hasError: boolean; children: React.ReactNode; - minHeight?: number; - appLink?: string; - appLinkName?: string; + appLink?: AppLink; } -export const SectionContainer = ({ title, appLink, children, hasError, appLinkName }: Props) => { +export const SectionContainer = ({ title, appLink, children, hasError }: Props) => { const { core } = usePluginContext(); return ( } extraAction={ - appLink && ( - - - {appLinkName - ? appLinkName - : i18n.translate('xpack.observability.chart.viewInAppLabel', { - defaultMessage: 'View in app', - })} - + appLink?.href && ( + + {appLink.label} ) } diff --git a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx index f3ba2ef6fa83a8..9b232ea33cbfbb 100644 --- a/x-pack/plugins/observability/public/components/app/section/logs/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/logs/index.tsx @@ -25,8 +25,8 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -45,21 +45,26 @@ function getColorPerItem(series?: LogsFetchDataResponse['series']) { return colorsPerItem; } -export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => { +export const LogsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('infra_logs')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('infra_logs')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); const formatter = niceTimeFormatter([min, max]); - const { title, appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const colorsPerItem = getColorPerItem(series); @@ -67,8 +72,15 @@ export const LogsSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx index 6276e1ba1bacad..9e5fdadaf4e5fd 100644 --- a/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/metrics/index.tsx @@ -18,8 +18,8 @@ import { ChartContainer } from '../../chart_container'; import { StyledStat } from '../../styled_stat'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } @@ -46,17 +46,23 @@ const StyledProgress = styled.div<{ color?: string }>` } `; -export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => { +export const MetricsSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); + + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('infra_metrics')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('infra_metrics')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); const isLoading = status === FETCH_STATUS.LOADING; - const { title = 'Metrics', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const cpuColor = theme.eui.euiColorVis7; const memoryColor = theme.eui.euiColorVis0; @@ -65,9 +71,15 @@ export const MetricsSection = ({ startTime, endTime, bucketSize }: Props) => { return ( diff --git a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx index 1f8ca6e61f1329..73a566460a593c 100644 --- a/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx +++ b/x-pack/plugins/observability/public/components/app/section/uptime/index.tsx @@ -30,37 +30,49 @@ import { StyledStat } from '../../styled_stat'; import { onBrushEnd } from '../helper'; interface Props { - startTime?: string; - endTime?: string; + absoluteTime: { start?: number; end?: number }; + relativeTime: { start: string; end: string }; bucketSize?: string; } -export const UptimeSection = ({ startTime, endTime, bucketSize }: Props) => { +export const UptimeSection = ({ absoluteTime, relativeTime, bucketSize }: Props) => { const theme = useContext(ThemeContext); const history = useHistory(); + const { start, end } = absoluteTime; const { data, status } = useFetcher(() => { - if (startTime && endTime && bucketSize) { - return getDataHandler('uptime')?.fetchData({ startTime, endTime, bucketSize }); + if (start && end && bucketSize) { + return getDataHandler('uptime')?.fetchData({ + absoluteTime: { start, end }, + relativeTime, + bucketSize, + }); } - }, [startTime, endTime, bucketSize]); + }, [start, end, bucketSize]); + + const min = moment.utc(absoluteTime.start).valueOf(); + const max = moment.utc(absoluteTime.end).valueOf(); - const min = moment.utc(startTime).valueOf(); - const max = moment.utc(endTime).valueOf(); const formatter = niceTimeFormatter([min, max]); const isLoading = status === FETCH_STATUS.LOADING; - const { title = 'Uptime', appLink, stats, series } = data || {}; + const { appLink, stats, series } = data || {}; const downColor = theme.eui.euiColorVis2; const upColor = theme.eui.euiColorLightShade; return ( diff --git a/x-pack/plugins/observability/public/data_handler.test.ts b/x-pack/plugins/observability/public/data_handler.test.ts index 71c2c942239fdc..7170ffe1486dcc 100644 --- a/x-pack/plugins/observability/public/data_handler.test.ts +++ b/x-pack/plugins/observability/public/data_handler.test.ts @@ -4,10 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ import { registerDataHandler, getDataHandler } from './data_handler'; +import moment from 'moment'; const params = { - startTime: '0', - endTime: '1', + absoluteTime: { + start: moment('2020-07-02T13:25:11.629Z').valueOf(), + end: moment('2020-07-09T13:25:11.629Z').valueOf(), + }, + relativeTime: { + start: 'now-15m', + end: 'now', + }, bucketSize: '10s', }; diff --git a/x-pack/plugins/observability/public/pages/overview/index.tsx b/x-pack/plugins/observability/public/pages/overview/index.tsx index 3674e69ab57023..088fab032d930e 100644 --- a/x-pack/plugins/observability/public/pages/overview/index.tsx +++ b/x-pack/plugins/observability/public/pages/overview/index.tsx @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ import { EuiFlexGrid, EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, EuiSpacer } from '@elastic/eui'; -import moment from 'moment'; import React, { useContext } from 'react'; import { ThemeContext } from 'styled-components'; import { EmptySection } from '../../components/app/empty_section'; @@ -23,7 +22,7 @@ import { UI_SETTINGS, useKibanaUISettings } from '../../hooks/use_kibana_ui_sett import { usePluginContext } from '../../hooks/use_plugin_context'; import { RouteParams } from '../../routes'; import { getObservabilityAlerts } from '../../services/get_observability_alerts'; -import { getParsedDate } from '../../utils/date'; +import { getAbsoluteTime } from '../../utils/date'; import { getBucketSize } from '../../utils/get_bucket_size'; import { getEmptySections } from './empty_section'; import { LoadingObservability } from './loading_observability'; @@ -33,13 +32,9 @@ interface Props { routeParams: RouteParams<'/overview'>; } -function calculatetBucketSize({ startTime, endTime }: { startTime?: string; endTime?: string }) { - if (startTime && endTime) { - return getBucketSize({ - start: moment.utc(startTime).valueOf(), - end: moment.utc(endTime).valueOf(), - minInterval: '60s', - }); +function calculatetBucketSize({ start, end }: { start?: number; end?: number }) { + if (start && end) { + return getBucketSize({ start, end, minInterval: '60s' }); } } @@ -62,16 +57,22 @@ export const OverviewPage = ({ routeParams }: Props) => { return ; } - const { - rangeFrom = timePickerTime.from, - rangeTo = timePickerTime.to, - refreshInterval = 10000, - refreshPaused = true, - } = routeParams.query; + const { refreshInterval = 10000, refreshPaused = true } = routeParams.query; - const startTime = getParsedDate(rangeFrom); - const endTime = getParsedDate(rangeTo, { roundUp: true }); - const bucketSize = calculatetBucketSize({ startTime, endTime }); + const relativeTime = { + start: routeParams.query.rangeFrom ?? timePickerTime.from, + end: routeParams.query.rangeTo ?? timePickerTime.to, + }; + + const absoluteTime = { + start: getAbsoluteTime(relativeTime.start), + end: getAbsoluteTime(relativeTime.end, { roundUp: true }), + }; + + const bucketSize = calculatetBucketSize({ + start: absoluteTime.start, + end: absoluteTime.end, + }); const appEmptySections = getEmptySections({ core }).filter(({ id }) => { if (id === 'alert') { @@ -93,8 +94,8 @@ export const OverviewPage = ({ routeParams }: Props) => { @@ -116,8 +117,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.infra_logs && ( @@ -125,8 +126,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.infra_metrics && ( @@ -134,8 +135,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.apm && ( @@ -143,8 +144,8 @@ export const OverviewPage = ({ routeParams }: Props) => { {hasData.uptime && ( diff --git a/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts index 7303b78cc01329..6a0e1a64aa115d 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/apm.mock.ts @@ -10,7 +10,6 @@ export const fetchApmData: FetchData = () => { }; const response: ApmFetchDataResponse = { - title: 'APM', appLink: '/app/apm', stats: { services: { @@ -607,7 +606,6 @@ const response: ApmFetchDataResponse = { }; export const emptyResponse: ApmFetchDataResponse = { - title: 'APM', appLink: '/app/apm', stats: { services: { diff --git a/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts index 5bea1fbf19ace1..8d1fb4d59c2cc7 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/logs.mock.ts @@ -11,7 +11,6 @@ export const fetchLogsData: FetchData = () => { }; const response: LogsFetchDataResponse = { - title: 'Logs', appLink: "/app/logs/stream?logPosition=(end:'2020-06-30T21:30:00.000Z',start:'2020-06-27T22:00:00.000Z')", stats: { @@ -2319,7 +2318,6 @@ const response: LogsFetchDataResponse = { }; export const emptyResponse: LogsFetchDataResponse = { - title: 'Logs', appLink: '/app/logs', stats: {}, series: {}, diff --git a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts index 37233b4f6342ce..d5a7992ceabd8b 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/metrics.mock.ts @@ -11,7 +11,6 @@ export const fetchMetricsData: FetchData = () => { }; const response: MetricsFetchDataResponse = { - title: 'Metrics', appLink: '/app/apm', stats: { hosts: { value: 11, type: 'number' }, @@ -113,7 +112,6 @@ const response: MetricsFetchDataResponse = { }; export const emptyResponse: MetricsFetchDataResponse = { - title: 'Metrics', appLink: '/app/apm', stats: { hosts: { value: 0, type: 'number' }, diff --git a/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts b/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts index ab5874f8bfcd44..c4fa09ceb11f77 100644 --- a/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts +++ b/x-pack/plugins/observability/public/pages/overview/mock/uptime.mock.ts @@ -10,7 +10,6 @@ export const fetchUptimeData: FetchData = () => { }; const response: UptimeFetchDataResponse = { - title: 'Uptime', appLink: '/app/uptime#/', stats: { monitors: { @@ -1191,7 +1190,6 @@ const response: UptimeFetchDataResponse = { }; export const emptyResponse: UptimeFetchDataResponse = { - title: 'Uptime', appLink: '/app/uptime#/', stats: { monitors: { diff --git a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts index 2dafd70896cc5e..a3d7308ff9e4ab 100644 --- a/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts +++ b/x-pack/plugins/observability/public/typings/fetch_overview_data/index.ts @@ -21,11 +21,8 @@ export interface Series { } export interface FetchDataParams { - // The start timestamp in milliseconds of the queried time interval - startTime: string; - // The end timestamp in milliseconds of the queried time interval - endTime: string; - // The aggregation bucket size in milliseconds if applicable to the data source + absoluteTime: { start: number; end: number }; + relativeTime: { start: string; end: string }; bucketSize: string; } @@ -41,7 +38,6 @@ export interface DataHandler { } export interface FetchDataResponse { - title: string; appLink: string; } diff --git a/x-pack/plugins/observability/public/utils/date.ts b/x-pack/plugins/observability/public/utils/date.ts index fc0bbdae20cb91..bdc89ad6e8fc01 100644 --- a/x-pack/plugins/observability/public/utils/date.ts +++ b/x-pack/plugins/observability/public/utils/date.ts @@ -5,11 +5,9 @@ */ import datemath from '@elastic/datemath'; -export function getParsedDate(range?: string, opts = {}) { - if (range) { - const parsed = datemath.parse(range, opts); - if (parsed) { - return parsed.toISOString(); - } +export function getAbsoluteTime(range: string, opts = {}) { + const parsed = datemath.parse(range, opts); + if (parsed) { + return parsed.valueOf(); } } diff --git a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts index 89720b275c63d6..d1e394dd4da6b5 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts +++ b/x-pack/plugins/uptime/public/apps/uptime_overview_fetcher.ts @@ -5,27 +5,24 @@ */ import { fetchPingHistogram, fetchSnapshotCount } from '../state/api'; -import { UptimeFetchDataResponse } from '../../../observability/public'; +import { UptimeFetchDataResponse, FetchDataParams } from '../../../observability/public'; export async function fetchUptimeOverviewData({ - startTime, - endTime, + absoluteTime, + relativeTime, bucketSize, -}: { - startTime: string; - endTime: string; - bucketSize: string; -}) { +}: FetchDataParams) { + const start = new Date(absoluteTime.start).toISOString(); + const end = new Date(absoluteTime.end).toISOString(); const snapshot = await fetchSnapshotCount({ - dateRangeStart: startTime, - dateRangeEnd: endTime, + dateRangeStart: start, + dateRangeEnd: end, }); - const pings = await fetchPingHistogram({ dateStart: startTime, dateEnd: endTime, bucketSize }); + const pings = await fetchPingHistogram({ dateStart: start, dateEnd: end, bucketSize }); const response: UptimeFetchDataResponse = { - title: 'Uptime', - appLink: '/app/uptime#/', + appLink: `/app/uptime#/?dateRangeStart=${relativeTime.start}&dateRangeEnd=${relativeTime.end}`, stats: { monitors: { type: 'number', From 90f233b5ebf774c887fc6f28249bd7770a61649f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Marcondes?= <55978943+cauemarcondes@users.noreply.github.com> Date: Tue, 14 Jul 2020 11:20:12 +0100 Subject: [PATCH 18/57] [APM] Use status_code field to calculate error rate (#71109) * calculating error rate based on status code * fixing unit test * addressing pr comments * adding erroneous transactions rate * adding erroneous transactions rate * adding error rate to detail page * fixing i18n Co-authored-by: Elastic Machine --- .../elasticsearch_fieldnames.test.ts.snap | 6 + .../apm/common/elasticsearch_fieldnames.ts | 1 + .../ErrorGroupDetails/Distribution/index.tsx | 2 + .../app/ErrorGroupDetails/index.tsx | 37 +++--- .../app/ErrorGroupOverview/index.tsx | 35 ++---- .../app/TransactionDetails/index.tsx | 11 +- .../app/TransactionOverview/index.tsx | 11 +- .../TransactionBreakdownHeader.tsx | 50 -------- .../shared/TransactionBreakdown/index.tsx | 51 ++++---- .../index.tsx | 34 +++--- .../shared/charts/Histogram/index.js | 7 +- .../apm/server/lib/errors/get_error_rate.ts | 109 ------------------ .../lib/transaction_groups/get_error_rate.ts | 86 ++++++++++++++ .../apm/server/routes/create_apm_api.ts | 4 +- x-pack/plugins/apm/server/routes/errors.ts | 24 ---- .../apm/server/routes/transaction_groups.ts | 30 +++++ .../translations/translations/ja-JP.json | 2 - .../translations/translations/zh-CN.json | 2 - 18 files changed, 219 insertions(+), 283 deletions(-) delete mode 100644 x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx rename x-pack/plugins/apm/public/components/shared/charts/{ErrorRateChart => ErroneousTransactionsRateChart}/index.tsx (79%) delete mode 100644 x-pack/plugins/apm/server/lib/errors/get_error_rate.ts create mode 100644 x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts diff --git a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap index 06ca3145bfce9a..f7f28367453843 100644 --- a/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap +++ b/x-pack/plugins/apm/common/__snapshots__/elasticsearch_fieldnames.test.ts.snap @@ -38,6 +38,8 @@ exports[`Error HOST_NAME 1`] = `"my hostname"`; exports[`Error HTTP_REQUEST_METHOD 1`] = `undefined`; +exports[`Error HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; + exports[`Error LABEL_NAME 1`] = `undefined`; exports[`Error METRIC_JAVA_GC_COUNT 1`] = `undefined`; @@ -182,6 +184,8 @@ exports[`Span HOST_NAME 1`] = `undefined`; exports[`Span HTTP_REQUEST_METHOD 1`] = `undefined`; +exports[`Span HTTP_RESPONSE_STATUS_CODE 1`] = `undefined`; + exports[`Span LABEL_NAME 1`] = `undefined`; exports[`Span METRIC_JAVA_GC_COUNT 1`] = `undefined`; @@ -326,6 +330,8 @@ exports[`Transaction HOST_NAME 1`] = `"my hostname"`; exports[`Transaction HTTP_REQUEST_METHOD 1`] = `"GET"`; +exports[`Transaction HTTP_RESPONSE_STATUS_CODE 1`] = `200`; + exports[`Transaction LABEL_NAME 1`] = `undefined`; exports[`Transaction METRIC_JAVA_GC_COUNT 1`] = `undefined`; diff --git a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts index a5a42ccbb9a21c..d8d3827909b07a 100644 --- a/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts +++ b/x-pack/plugins/apm/common/elasticsearch_fieldnames.ts @@ -24,6 +24,7 @@ export const AGENT_VERSION = 'agent.version'; export const URL_FULL = 'url.full'; export const HTTP_REQUEST_METHOD = 'http.request.method'; +export const HTTP_RESPONSE_STATUS_CODE = 'http.response.status_code'; export const USER_ID = 'user.id'; export const USER_AGENT_ORIGINAL = 'user_agent.original'; export const USER_AGENT_NAME = 'user_agent.name'; diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index 3cd04ee032e561..aa95918939dfac 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -12,6 +12,7 @@ import d3 from 'd3'; import { scaleUtc } from 'd3-scale'; import { mean } from 'lodash'; import React from 'react'; +import { px } from '../../../../style/variables'; import { asRelativeDateTimeRange } from '../../../../utils/formatters'; import { getTimezoneOffsetInMs } from '../../../shared/charts/CustomPlot/getTimezoneOffsetInMs'; // @ts-ignore @@ -88,6 +89,7 @@ export function ErrorDistribution({ distribution, title }: Props) { {title} bucket.x} diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx index b765dc42ede644..31f299f94bc262 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/index.tsx @@ -16,18 +16,16 @@ import { import { i18n } from '@kbn/i18n'; import React, { Fragment } from 'react'; import styled from 'styled-components'; +import { useTrackPageview } from '../../../../../observability/public'; import { NOT_AVAILABLE_LABEL } from '../../../../common/i18n'; import { useFetcher } from '../../../hooks/useFetcher'; +import { useLocation } from '../../../hooks/useLocation'; +import { useUrlParams } from '../../../hooks/useUrlParams'; +import { callApmApi } from '../../../services/rest/createCallApmApi'; import { fontFamilyCode, fontSizes, px, units } from '../../../style/variables'; import { ApmHeader } from '../../shared/ApmHeader'; import { DetailView } from './DetailView'; import { ErrorDistribution } from './Distribution'; -import { useLocation } from '../../../hooks/useLocation'; -import { useUrlParams } from '../../../hooks/useUrlParams'; -import { useTrackPageview } from '../../../../../observability/public'; -import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { ErrorRateChart } from '../../shared/charts/ErrorRateChart'; -import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; const Titles = styled.div` margin-bottom: ${px(units.plus)}; @@ -181,24 +179,15 @@ export function ErrorGroupDetails() { )} - - - - - - - - - - + {showDetails && ( diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx index 73474208e26c02..b9a28c1c1841f1 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupOverview/index.tsx @@ -18,11 +18,9 @@ import { PROJECTION } from '../../../../common/projections/typings'; import { useFetcher } from '../../../hooks/useFetcher'; import { useUrlParams } from '../../../hooks/useUrlParams'; import { callApmApi } from '../../../services/rest/createCallApmApi'; -import { ErrorRateChart } from '../../shared/charts/ErrorRateChart'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { ErrorDistribution } from '../ErrorGroupDetails/Distribution'; import { ErrorGroupList } from './List'; -import { ChartsSyncContextProvider } from '../../../context/ChartsSyncContext'; const ErrorGroupOverview: React.FC = () => { const { urlParams, uiFilters } = useUrlParams(); @@ -99,28 +97,17 @@ const ErrorGroupOverview: React.FC = () => {
- - - - - - - - - - - - - - + + + diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx index c56b7b9aaa7205..c4d5be5874215f 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx @@ -13,6 +13,7 @@ import { EuiFlexItem, } from '@elastic/eui'; import React, { useMemo } from 'react'; +import { EuiFlexGrid } from '@elastic/eui'; import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { useTransactionDistribution } from '../../../hooks/useTransactionDistribution'; import { useWaterfall } from '../../../hooks/useWaterfall'; @@ -29,6 +30,7 @@ import { useTrackPageview } from '../../../../../observability/public'; import { PROJECTION } from '../../../../common/projections/typings'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { HeightRetainer } from '../../shared/HeightRetainer'; +import { ErroneousTransactionsRateChart } from '../../shared/charts/ErroneousTransactionsRateChart'; export function TransactionDetails() { const location = useLocation(); @@ -84,7 +86,14 @@ export function TransactionDetails() { - + + + + + + + + diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx index 4ceeec8c502216..98702fe3686ff4 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -19,10 +19,12 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { first } from 'lodash'; import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; +import { EuiFlexGrid } from '@elastic/eui'; import { useTransactionList } from '../../../hooks/useTransactionList'; import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; import { TransactionCharts } from '../../shared/charts/TransactionCharts'; +import { ErroneousTransactionsRateChart } from '../../shared/charts/ErroneousTransactionsRateChart'; import { TransactionBreakdown } from '../../shared/TransactionBreakdown'; import { TransactionList } from './List'; import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; @@ -125,7 +127,14 @@ export function TransactionOverview() { - + + + + + + + + diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx deleted file mode 100644 index 3a0fb3dd17eec4..00000000000000 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownHeader.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; - -import { - EuiTitle, - EuiFlexGroup, - EuiFlexItem, - EuiButtonEmpty, -} from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; - -const TransactionBreakdownHeader: React.FC<{ - showChart: boolean; - onToggleClick: () => void; -}> = ({ showChart, onToggleClick }) => { - return ( - - - -

- {i18n.translate('xpack.apm.transactionBreakdown.chartTitle', { - defaultMessage: 'Time spent by span type', - })} -

-
-
- - onToggleClick()} - > - {showChart - ? i18n.translate('xpack.apm.transactionBreakdown.hideChart', { - defaultMessage: 'Hide chart', - }) - : i18n.translate('xpack.apm.transactionBreakdown.showChart', { - defaultMessage: 'Show chart', - })} - - -
- ); -}; - -export { TransactionBreakdownHeader }; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx index 75ae4e44cfede6..51cad6bc65a853 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx @@ -3,58 +3,51 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiText } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiPanel, + EuiText, + EuiTitle, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; import { useTransactionBreakdown } from '../../../hooks/useTransactionBreakdown'; -import { TransactionBreakdownHeader } from './TransactionBreakdownHeader'; -import { TransactionBreakdownKpiList } from './TransactionBreakdownKpiList'; import { TransactionBreakdownGraph } from './TransactionBreakdownGraph'; -import { FETCH_STATUS } from '../../../hooks/useFetcher'; -import { useUiTracker } from '../../../../../observability/public'; +import { TransactionBreakdownKpiList } from './TransactionBreakdownKpiList'; const emptyMessage = i18n.translate('xpack.apm.transactionBreakdown.noData', { defaultMessage: 'No data within this time range.', }); -const TransactionBreakdown: React.FC<{ - initialIsOpen?: boolean; -}> = ({ initialIsOpen }) => { - const [showChart, setShowChart] = useState(!!initialIsOpen); +const TransactionBreakdown = () => { const { data, status } = useTransactionBreakdown(); - const trackApmEvent = useUiTracker({ app: 'apm' }); const { kpis, timeseries } = data; const noHits = data.kpis.length === 0 && status === FETCH_STATUS.SUCCESS; - const showEmptyMessage = noHits && !showChart; return ( - { - setShowChart(!showChart); - if (showChart) { - trackApmEvent({ metric: 'hide_breakdown_chart' }); - } else { - trackApmEvent({ metric: 'show_breakdown_chart' }); - } - }} - /> + +

+ {i18n.translate('xpack.apm.transactionBreakdown.chartTitle', { + defaultMessage: 'Time spent by span type', + })} +

+
- {showEmptyMessage ? ( + {noHits ? ( {emptyMessage} ) : ( )} - {showChart ? ( - - - - ) : null} + + +
); diff --git a/x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx similarity index 79% rename from x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx index de60441f4faa0b..f87be32b43fc1c 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/ErrorRateChart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/ErroneousTransactionsRateChart/index.tsx @@ -8,11 +8,11 @@ import theme from '@elastic/eui/dist/eui_theme_light.json'; import { i18n } from '@kbn/i18n'; import { mean } from 'lodash'; import React, { useCallback } from 'react'; +import { EuiPanel } from '@elastic/eui'; import { useChartsSync } from '../../../../hooks/useChartsSync'; import { useFetcher } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { callApmApi } from '../../../../services/rest/createCallApmApi'; -import { unit } from '../../../../style/variables'; import { asPercent } from '../../../../utils/formatters'; // @ts-ignore import CustomPlot from '../CustomPlot'; @@ -21,15 +21,23 @@ const tickFormatY = (y?: number) => { return asPercent(y || 0, 1); }; -export const ErrorRateChart = () => { +export const ErroneousTransactionsRateChart = () => { const { urlParams, uiFilters } = useUrlParams(); const syncedChartsProps = useChartsSync(); - const { serviceName, start, end, errorGroupId } = urlParams; - const { data: errorRateData } = useFetcher(() => { + const { + serviceName, + start, + end, + transactionType, + transactionName, + } = urlParams; + + const { data } = useFetcher(() => { if (serviceName && start && end) { return callApmApi({ - pathname: '/api/apm/services/{serviceName}/errors/rate', + pathname: + '/api/apm/services/{serviceName}/transaction_groups/error_rate', params: { path: { serviceName, @@ -37,13 +45,14 @@ export const ErrorRateChart = () => { query: { start, end, + transactionType, + transactionName, uiFilters: JSON.stringify(uiFilters), - groupId: errorGroupId, }, }, }); } - }, [serviceName, start, end, uiFilters, errorGroupId]); + }, [serviceName, start, end, uiFilters, transactionType, transactionName]); const combinedOnHover = useCallback( (hoverX: number) => { @@ -52,20 +61,20 @@ export const ErrorRateChart = () => { [syncedChartsProps] ); - const errorRates = errorRateData?.errorRates || []; + const errorRates = data?.erroneousTransactionsRate || []; return ( - <> + {i18n.translate('xpack.apm.errorRateChart.title', { - defaultMessage: 'Error Rate', + defaultMessage: 'Transaction error rate', })} { formatTooltipValue={({ y }: { y?: number }) => Number.isFinite(y) ? tickFormatY(y) : 'N/A' } - height={unit * 10} /> - + ); }; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js index 002ff19d0d1df2..3b2109d68c613d 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js +++ b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js @@ -103,6 +103,7 @@ export class HistogramInner extends PureComponent { tooltipHeader, verticalLineHover, width: XY_WIDTH, + height, legends, } = this.props; const { hoveredBucket } = this.state; @@ -181,7 +182,7 @@ export class HistogramInner extends PureComponent { ); return ( -
+
{noHits ? ( <>{emptyStateChart} @@ -250,7 +251,7 @@ export class HistogramInner extends PureComponent { { return { @@ -297,6 +298,7 @@ HistogramInner.propTypes = { tooltipHeader: PropTypes.func, verticalLineHover: PropTypes.func, width: PropTypes.number.isRequired, + height: PropTypes.number, xType: PropTypes.string, legends: PropTypes.array, noHits: PropTypes.bool, @@ -311,6 +313,7 @@ HistogramInner.defaultProps = { verticalLineHover: () => null, xType: 'linear', noHits: false, + height: XY_HEIGHT, }; export default makeWidthFlexible(HistogramInner); diff --git a/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts b/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts deleted file mode 100644 index e91d3953942d91..00000000000000 --- a/x-pack/plugins/apm/server/lib/errors/get_error_rate.ts +++ /dev/null @@ -1,109 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { - ERROR_GROUP_ID, - PROCESSOR_EVENT, - SERVICE_NAME, -} from '../../../common/elasticsearch_fieldnames'; -import { ProcessorEvent } from '../../../common/processor_event'; -import { getMetricsDateHistogramParams } from '../helpers/metrics'; -import { - Setup, - SetupTimeRange, - SetupUIFilters, -} from '../helpers/setup_request'; -import { rangeFilter } from '../../../common/utils/range_filter'; - -export async function getErrorRate({ - serviceName, - groupId, - setup, -}: { - serviceName: string; - groupId?: string; - setup: Setup & SetupTimeRange & SetupUIFilters; -}) { - const { start, end, uiFiltersES, client, indices } = setup; - - const filter = [ - { term: { [SERVICE_NAME]: serviceName } }, - { range: rangeFilter(start, end) }, - ...uiFiltersES, - ]; - - const aggs = { - response_times: { - date_histogram: getMetricsDateHistogramParams(start, end), - }, - }; - - const getTransactionBucketAggregation = async () => { - const resp = await client.search({ - index: indices['apm_oss.transactionIndices'], - body: { - size: 0, - query: { - bool: { - filter: [ - ...filter, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, - ], - }, - }, - aggs, - }, - }); - return { - totalHits: resp.hits.total.value, - responseTimeBuckets: resp.aggregations?.response_times.buckets, - }; - }; - const getErrorBucketAggregation = async () => { - const groupIdFilter = groupId - ? [{ term: { [ERROR_GROUP_ID]: groupId } }] - : []; - const resp = await client.search({ - index: indices['apm_oss.errorIndices'], - body: { - size: 0, - query: { - bool: { - filter: [ - ...filter, - ...groupIdFilter, - { term: { [PROCESSOR_EVENT]: ProcessorEvent.error } }, - ], - }, - }, - aggs, - }, - }); - return resp.aggregations?.response_times.buckets; - }; - - const [transactions, errorResponseTimeBuckets] = await Promise.all([ - getTransactionBucketAggregation(), - getErrorBucketAggregation(), - ]); - - const transactionCountByTimestamp: Record = {}; - if (transactions?.responseTimeBuckets) { - transactions.responseTimeBuckets.forEach((bucket) => { - transactionCountByTimestamp[bucket.key] = bucket.doc_count; - }); - } - - const errorRates = errorResponseTimeBuckets?.map((bucket) => { - const { key, doc_count: errorCount } = bucket; - const relativeRate = errorCount / transactionCountByTimestamp[key]; - return { x: key, y: relativeRate }; - }); - - return { - noHits: transactions?.totalHits === 0, - errorRates, - }; -} diff --git a/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts new file mode 100644 index 00000000000000..5b66f7d7a45e72 --- /dev/null +++ b/x-pack/plugins/apm/server/lib/transaction_groups/get_error_rate.ts @@ -0,0 +1,86 @@ +/* + * 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 { + PROCESSOR_EVENT, + HTTP_RESPONSE_STATUS_CODE, + TRANSACTION_NAME, + TRANSACTION_TYPE, +} from '../../../common/elasticsearch_fieldnames'; +import { ProcessorEvent } from '../../../common/processor_event'; +import { rangeFilter } from '../../../common/utils/range_filter'; +import { getMetricsDateHistogramParams } from '../helpers/metrics'; +import { + Setup, + SetupTimeRange, + SetupUIFilters, +} from '../helpers/setup_request'; + +export async function getErrorRate({ + serviceName, + transactionType, + transactionName, + setup, +}: { + serviceName: string; + transactionType?: string; + transactionName?: string; + setup: Setup & SetupTimeRange & SetupUIFilters; +}) { + const { start, end, uiFiltersES, client, indices } = setup; + + const transactionNamefilter = transactionName + ? [{ term: { [TRANSACTION_NAME]: transactionName } }] + : []; + const transactionTypefilter = transactionType + ? [{ term: { [TRANSACTION_TYPE]: transactionType } }] + : []; + + const filter = [ + { term: { [PROCESSOR_EVENT]: ProcessorEvent.transaction } }, + { range: rangeFilter(start, end) }, + { exists: { field: HTTP_RESPONSE_STATUS_CODE } }, + ...transactionNamefilter, + ...transactionTypefilter, + ...uiFiltersES, + ]; + + const params = { + index: indices['apm_oss.transactionIndices'], + body: { + size: 0, + query: { bool: { filter } }, + aggs: { + total_transactions: { + date_histogram: getMetricsDateHistogramParams(start, end), + aggs: { + erroneous_transactions: { + filter: { range: { [HTTP_RESPONSE_STATUS_CODE]: { gte: 400 } } }, + }, + }, + }, + }, + }, + }; + + const resp = await client.search(params); + + const noHits = resp.hits.total.value === 0; + + const erroneousTransactionsRate = + resp.aggregations?.total_transactions.buckets.map( + ({ key, doc_count: totalTransactions, erroneous_transactions }) => { + const errornousTransactionsCount = + // @ts-ignore + erroneous_transactions.doc_count; + return { + x: key, + y: errornousTransactionsCount / totalTransactions, + }; + } + ) || []; + + return { noHits, erroneousTransactionsRate }; +} diff --git a/x-pack/plugins/apm/server/routes/create_apm_api.ts b/x-pack/plugins/apm/server/routes/create_apm_api.ts index 0a4295fea39978..4e3aa6d4ebe1d2 100644 --- a/x-pack/plugins/apm/server/routes/create_apm_api.ts +++ b/x-pack/plugins/apm/server/routes/create_apm_api.ts @@ -13,7 +13,6 @@ import { errorDistributionRoute, errorGroupsRoute, errorsRoute, - errorRateRoute, } from './errors'; import { serviceAgentNameRoute, @@ -49,6 +48,7 @@ import { transactionGroupsRoute, transactionGroupsAvgDurationByCountry, transactionGroupsAvgDurationByBrowser, + transactionGroupsErrorRateRoute, } from './transaction_groups'; import { errorGroupsLocalFiltersRoute, @@ -99,7 +99,6 @@ const createApmApi = () => { .add(errorDistributionRoute) .add(errorGroupsRoute) .add(errorsRoute) - .add(errorRateRoute) // Services .add(serviceAgentNameRoute) @@ -139,6 +138,7 @@ const createApmApi = () => { .add(transactionGroupsRoute) .add(transactionGroupsAvgDurationByBrowser) .add(transactionGroupsAvgDurationByCountry) + .add(transactionGroupsErrorRateRoute) // UI filters .add(errorGroupsLocalFiltersRoute) diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index 97314a9a616611..1615550027d3cd 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -11,7 +11,6 @@ import { getErrorGroup } from '../lib/errors/get_error_group'; import { getErrorGroups } from '../lib/errors/get_error_groups'; import { setupRequest } from '../lib/helpers/setup_request'; import { uiFiltersRt, rangeRt } from './default_api_types'; -import { getErrorRate } from '../lib/errors/get_error_rate'; export const errorsRoute = createRoute(() => ({ path: '/api/apm/services/{serviceName}/errors', @@ -81,26 +80,3 @@ export const errorDistributionRoute = createRoute(() => ({ return getErrorDistribution({ serviceName, groupId, setup }); }, })); - -export const errorRateRoute = createRoute(() => ({ - path: '/api/apm/services/{serviceName}/errors/rate', - params: { - path: t.type({ - serviceName: t.string, - }), - query: t.intersection([ - t.partial({ - groupId: t.string, - }), - uiFiltersRt, - rangeRt, - ]), - }, - handler: async ({ context, request }) => { - const setup = await setupRequest(context, request); - const { params } = context; - const { serviceName } = params.path; - const { groupId } = params.query; - return getErrorRate({ serviceName, groupId, setup }); - }, -})); diff --git a/x-pack/plugins/apm/server/routes/transaction_groups.ts b/x-pack/plugins/apm/server/routes/transaction_groups.ts index 3d939b04795c67..dca2fb1d9b2955 100644 --- a/x-pack/plugins/apm/server/routes/transaction_groups.ts +++ b/x-pack/plugins/apm/server/routes/transaction_groups.ts @@ -14,6 +14,7 @@ import { createRoute } from './create_route'; import { uiFiltersRt, rangeRt } from './default_api_types'; import { getTransactionAvgDurationByBrowser } from '../lib/transactions/avg_duration_by_browser'; import { getTransactionAvgDurationByCountry } from '../lib/transactions/avg_duration_by_country'; +import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; import { UIFilters } from '../../typings/ui_filters'; export const transactionGroupsRoute = createRoute(() => ({ @@ -209,3 +210,32 @@ export const transactionGroupsAvgDurationByCountry = createRoute(() => ({ }); }, })); + +export const transactionGroupsErrorRateRoute = createRoute(() => ({ + path: '/api/apm/services/{serviceName}/transaction_groups/error_rate', + params: { + path: t.type({ + serviceName: t.string, + }), + query: t.intersection([ + uiFiltersRt, + rangeRt, + t.partial({ + transactionType: t.string, + transactionName: t.string, + }), + ]), + }, + handler: async ({ context, request }) => { + const setup = await setupRequest(context, request); + const { params } = context; + const { serviceName } = params.path; + const { transactionType, transactionName } = params.query; + return getErrorRate({ + serviceName, + transactionType, + transactionName, + setup, + }); + }, +})); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index ef95f5f9c09d8f..5734056f36bd91 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -4481,9 +4481,7 @@ "xpack.apm.transactionActionMenu.viewInUptime": "ステータス", "xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel": "サンプルドキュメントを表示", "xpack.apm.transactionBreakdown.chartTitle": "スパンタイプ別時間", - "xpack.apm.transactionBreakdown.hideChart": "グラフを非表示", "xpack.apm.transactionBreakdown.noData": "この時間範囲のデータがありません。", - "xpack.apm.transactionBreakdown.showChart": "グラフを表示", "xpack.apm.transactionDetails.errorCount": "{errorCount, number} {errorCount, plural, one {件のエラー} other {件のエラー}}", "xpack.apm.transactionDetails.errorsOverviewLinkTooltip": "{errorCount, plural, one {1 件の関連エラーを表示} other {# 件の関連エラーを表示}}", "xpack.apm.transactionDetails.notFoundLabel": "トランザクションが見つかりませんでした。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 108fb4ba320463..823a787a11e5d0 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -4485,9 +4485,7 @@ "xpack.apm.transactionActionMenu.viewInUptime": "状态", "xpack.apm.transactionActionMenu.viewSampleDocumentLinkLabel": "查看样例文档", "xpack.apm.transactionBreakdown.chartTitle": "跨度类型花费的时间", - "xpack.apm.transactionBreakdown.hideChart": "隐藏图表", "xpack.apm.transactionBreakdown.noData": "此时间范围内没有数据。", - "xpack.apm.transactionBreakdown.showChart": "显示图表", "xpack.apm.transactionDetails.errorCount": "{errorCount, number} 个 {errorCount, plural, one {错误} other {错误}}", "xpack.apm.transactionDetails.errorsOverviewLinkTooltip": "{errorCount, plural, one {查看 1 个相关错误} other {查看 # 个相关错误}}", "xpack.apm.transactionDetails.notFoundLabel": "未找到任何事务。", From 57144f9d274fd4dab740d3614904a493493cf9d5 Mon Sep 17 00:00:00 2001 From: Robert Oskamp Date: Tue, 14 Jul 2020 12:38:37 +0200 Subject: [PATCH 19/57] [ML] Functional tests - disable DFA creation and cloning tests --- x-pack/test/functional/apps/ml/data_frame_analytics/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts index 0202c8431ce348..a2ac236a5ea274 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/index.ts @@ -6,7 +6,8 @@ import { FtrProviderContext } from '../../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { - describe('data frame analytics', function () { + // flaky tests + describe.skip('data frame analytics', function () { this.tags(['mlqa', 'skipFirefox']); loadTestFile(require.resolve('./outlier_detection_creation')); From 5ef8d3f5091ba3ae36c125a0196065d95743fd8d Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Tue, 14 Jul 2020 05:54:29 -0500 Subject: [PATCH 20/57] [Metrics UI] Remove UUID from Alert Instance IDs (#71335) * [Metrics UI] Use alertId instead of uuid for alertInstanceIds --- x-pack/plugins/alerts/README.md | 6 ++-- .../inventory_metric_threshold_executor.ts | 10 +++---- ...r_inventory_metric_threshold_alert_type.ts | 4 +-- .../metric_threshold_executor.test.ts | 29 ++++++++++--------- .../metric_threshold_executor.ts | 4 +-- .../register_metric_threshold_alert_type.ts | 4 +-- 6 files changed, 28 insertions(+), 29 deletions(-) diff --git a/x-pack/plugins/alerts/README.md b/x-pack/plugins/alerts/README.md index 811478426a8d34..2f2ffb52e7e90b 100644 --- a/x-pack/plugins/alerts/README.md +++ b/x-pack/plugins/alerts/README.md @@ -482,13 +482,15 @@ A schedule is structured such that the key specifies the format you wish to use We currently support the _Interval format_ which specifies the interval in seconds, minutes, hours or days at which the alert should execute. Example: `{ interval: "10s" }`, `{ interval: "5m" }`, `{ interval: "1h" }`, `{ interval: "1d" }`. -There are plans to support multiple other schedule formats in the near fuiture. +There are plans to support multiple other schedule formats in the near future. ## Alert instance factory **alertInstanceFactory(id)** -One service passed in to alert types is an alert instance factory. This factory creates instances of alerts and must be used in order to execute actions. The id you give to the alert instance factory is a unique identifier to the alert instance (ex: server identifier if the instance is about the server). The instance factory will use this identifier to retrieve the state of previous instances with the same id. These instances support state persisting between alert type execution, but will clear out once the alert instance stops executing. +One service passed in to alert types is an alert instance factory. This factory creates instances of alerts and must be used in order to execute actions. The `id` you give to the alert instance factory is a unique identifier to the alert instance (ex: server identifier if the instance is about the server). The instance factory will use this identifier to retrieve the state of previous instances with the same `id`. These instances support state persisting between alert type execution, but will clear out once the alert instance stops executing. + +Note that the `id` only needs to be unique **within the scope of a specific alert**, not unique across all alerts or alert types. For example, Alert 1 and Alert 2 can both create an alert instance with an `id` of `"a"` without conflicting with one another. But if Alert 1 creates 2 alert instances, then they must be differentiated with `id`s of `"a"` and `"b"`. This factory returns an instance of `AlertInstance`. The alert instance class has the following methods, note that we have removed the methods that you shouldn't touch. diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts index 1ef86d9e7eac49..0a3910f2c5d7c5 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/inventory_metric_threshold_executor.ts @@ -29,10 +29,10 @@ interface InventoryMetricThresholdParams { alertOnNoData?: boolean; } -export const createInventoryMetricThresholdExecutor = ( - libs: InfraBackendLibs, - alertId: string -) => async ({ services, params }: AlertExecutorOptions) => { +export const createInventoryMetricThresholdExecutor = (libs: InfraBackendLibs) => async ({ + services, + params, +}: AlertExecutorOptions) => { const { criteria, filterQuery, @@ -54,7 +54,7 @@ export const createInventoryMetricThresholdExecutor = ( const inventoryItems = Object.keys(first(results) as any); for (const item of inventoryItems) { - const alertInstance = services.alertInstanceFactory(`${item}::${alertId}`); + const alertInstance = services.alertInstanceFactory(`${item}`); // AND logic; all criteria must be across the threshold const shouldAlertFire = results.every((result) => result[item].shouldFire); diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts index d7c4165d5a870d..85b38f48d9f226 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/register_inventory_metric_threshold_alert_type.ts @@ -5,8 +5,6 @@ */ import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; -import { curry } from 'lodash'; -import uuid from 'uuid'; import { createInventoryMetricThresholdExecutor, FIRED_ACTIONS, @@ -43,7 +41,7 @@ export const registerMetricInventoryThresholdAlertType = (libs: InfraBackendLibs defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], producer: 'metrics', - executor: curry(createInventoryMetricThresholdExecutor)(libs, uuid.v4()), + executor: createInventoryMetricThresholdExecutor(libs), actionVariables: { context: [ { diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts index 003a6c3c20e986..9a46925a51762e 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.test.ts @@ -24,7 +24,7 @@ let persistAlertInstances = false; // eslint-disable-line describe('The metric threshold alert type', () => { describe('querying the entire infrastructure', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[], sourceId: string = 'default') => executor({ services, @@ -120,8 +120,8 @@ describe('The metric threshold alert type', () => { ], }, }); - const instanceIdA = 'a::test'; - const instanceIdB = 'b::test'; + const instanceIdA = 'a'; + const instanceIdB = 'b'; test('sends an alert when all groups pass the threshold', async () => { await execute(Comparator.GT, [0.75]); expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); @@ -177,20 +177,20 @@ describe('The metric threshold alert type', () => { }, }); test('sends an alert when all criteria cross the threshold', async () => { - const instanceID = '*::test'; + const instanceID = '*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); expect(mostRecentAction(instanceID).id).toBe(FIRED_ACTIONS.id); expect(getState(instanceID).alertState).toBe(AlertStates.ALERT); }); test('sends no alert when some, but not all, criteria cross the threshold', async () => { - const instanceID = '*::test'; + const instanceID = '*'; await execute(Comparator.LT_OR_EQ, [1.0], [3.0]); expect(mostRecentAction(instanceID)).toBe(undefined); expect(getState(instanceID).alertState).toBe(AlertStates.OK); }); test('alerts only on groups that meet all criteria when querying with a groupBy parameter', async () => { - const instanceIdA = 'a::test'; - const instanceIdB = 'b::test'; + const instanceIdA = 'a'; + const instanceIdB = 'b'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0], 'something'); expect(mostRecentAction(instanceIdA).id).toBe(FIRED_ACTIONS.id); expect(getState(instanceIdA).alertState).toBe(AlertStates.ALERT); @@ -198,7 +198,7 @@ describe('The metric threshold alert type', () => { expect(getState(instanceIdB).alertState).toBe(AlertStates.OK); }); test('sends all criteria to the action context', async () => { - const instanceID = '*::test'; + const instanceID = '*'; await execute(Comparator.GT_OR_EQ, [1.0], [3.0]); const { action } = mostRecentAction(instanceID); const reasons = action.reason.split('\n'); @@ -212,7 +212,7 @@ describe('The metric threshold alert type', () => { }); }); describe('querying with the count aggregator', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[]) => executor({ services, @@ -238,7 +238,7 @@ describe('The metric threshold alert type', () => { }); }); describe('querying with the p99 aggregator', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[]) => executor({ services, @@ -264,7 +264,7 @@ describe('The metric threshold alert type', () => { }); }); describe('querying with the p95 aggregator', () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (comparator: Comparator, threshold: number[]) => executor({ services, @@ -290,7 +290,7 @@ describe('The metric threshold alert type', () => { }); }); describe("querying a metric that hasn't reported data", () => { - const instanceID = '*::test'; + const instanceID = '*'; const execute = (alertOnNoData: boolean) => executor({ services, @@ -319,9 +319,10 @@ describe('The metric threshold alert type', () => { }); // describe('querying a metric that later recovers', () => { - // const instanceID = '*::test'; + // const instanceID = '*'; // const execute = (threshold: number[]) => // executor({ + // // services, // params: { // criteria: [ @@ -379,7 +380,7 @@ const mockLibs: any = { configuration: createMockStaticConfiguration({}), }; -const executor = createMetricThresholdExecutor(mockLibs, 'test') as (opts: { +const executor = createMetricThresholdExecutor(mockLibs) as (opts: { params: AlertExecutorOptions['params']; services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; }) => Promise; diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts index bc1cc24f65eebf..b4754a8624fd52 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/metric_threshold_executor.ts @@ -17,7 +17,7 @@ import { import { AlertStates } from './types'; import { evaluateAlert } from './lib/evaluate_alert'; -export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: string) => +export const createMetricThresholdExecutor = (libs: InfraBackendLibs) => async function (options: AlertExecutorOptions) { const { services, params } = options; const { criteria } = params; @@ -36,7 +36,7 @@ export const createMetricThresholdExecutor = (libs: InfraBackendLibs, alertId: s // Because each alert result has the same group definitions, just grap the groups from the first one. const groups = Object.keys(first(alertResults) as any); for (const group of groups) { - const alertInstance = services.alertInstanceFactory(`${group}::${alertId}`); + const alertInstance = services.alertInstanceFactory(`${group}`); // AND logic; all criteria must be across the threshold const shouldAlertFire = alertResults.every((result) => diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts index 02d9ca3e5f0c93..529a1d176c4377 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/register_metric_threshold_alert_type.ts @@ -4,9 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ import { i18n } from '@kbn/i18n'; -import uuid from 'uuid'; import { schema } from '@kbn/config-schema'; -import { curry } from 'lodash'; import { METRIC_EXPLORER_AGGREGATIONS } from '../../../../common/http_api/metrics_explorer'; import { createMetricThresholdExecutor, FIRED_ACTIONS } from './metric_threshold_executor'; import { METRIC_THRESHOLD_ALERT_TYPE_ID, Comparator } from './types'; @@ -107,7 +105,7 @@ export function registerMetricThresholdAlertType(libs: InfraBackendLibs) { }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - executor: curry(createMetricThresholdExecutor)(libs, uuid.v4()), + executor: createMetricThresholdExecutor(libs), actionVariables: { context: [ { name: 'group', description: groupActionVariableDescription }, From 6c4fc9ca206d77992f2056f209d3689935a70c71 Mon Sep 17 00:00:00 2001 From: Zacqary Adam Xeper Date: Tue, 14 Jul 2020 05:55:05 -0500 Subject: [PATCH 21/57] [Logs UI] Remove UUID from Alert Instances (#71340) * [Logs UI] Remove UUID from Alert Instances * Fix bad template string Co-authored-by: Elastic Machine --- .../infra/server/lib/alerting/common/utils.ts | 2 ++ .../evaluate_condition.ts | 5 ++-- .../log_threshold_executor.test.ts | 24 +++++++++---------- .../log_threshold/log_threshold_executor.ts | 22 +++++++---------- .../register_log_threshold_alert_type.ts | 5 +--- .../metric_threshold/lib/evaluate_alert.ts | 7 +++--- 6 files changed, 31 insertions(+), 34 deletions(-) diff --git a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts index 100260c4996736..27eaeb8eee5ac8 100644 --- a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts +++ b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts @@ -29,3 +29,5 @@ export const validateIsStringElasticsearchJSONFilter = (value: string) => { return errorMessage; } }; + +export const UNGROUPED_FACTORY_KEY = '*'; diff --git a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts index 868ea5bfbffe11..c991e482a62e5b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts +++ b/x-pack/plugins/infra/server/lib/alerting/inventory_metric_threshold/evaluate_condition.ts @@ -20,6 +20,7 @@ import { parseFilterQuery } from '../../../utils/serialized_query'; import { InventoryItemType, SnapshotMetricType } from '../../../../common/inventory_models/types'; import { InfraTimerangeInput } from '../../../../common/http_api/snapshot_api'; import { InfraSourceConfiguration } from '../../sources'; +import { UNGROUPED_FACTORY_KEY } from '../common/utils'; type ConditionResult = InventoryMetricConditions & { shouldFire: boolean | boolean[]; @@ -129,14 +130,14 @@ const getData = async ( const causedByType = e.body?.error?.caused_by?.type; if (causedByType === 'too_many_buckets_exception') { return { - '*': { + [UNGROUPED_FACTORY_KEY]: { [TOO_MANY_BUCKETS_PREVIEW_EXCEPTION]: true, maxBuckets: e.body.error.caused_by.max_buckets, }, }; } } - return { '*': undefined }; + return { [UNGROUPED_FACTORY_KEY]: undefined }; } }; diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts index 4f1e81e0b2c40c..940afd72f6c73c 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.test.ts @@ -54,19 +54,19 @@ services.alertInstanceFactory.mockImplementation((instanceId: string) => { /* * Helper functions */ -function getAlertState(instanceId: string): AlertStates { - const alert = alertInstances.get(`${instanceId}-*`); +function getAlertState(): AlertStates { + const alert = alertInstances.get('*'); if (alert) { return alert.state.alertState; } else { - throw new Error('Could not find alert instance `' + instanceId + '`'); + throw new Error('Could not find alert instance'); } } /* * Executor instance (our test subject) */ -const executor = (createLogThresholdExecutor('test', libsMock) as unknown) as (opts: { +const executor = (createLogThresholdExecutor(libsMock) as unknown) as (opts: { params: LogDocumentCountAlertParams; services: { callCluster: AlertExecutorOptions['params']['callCluster'] }; }) => Promise; @@ -109,30 +109,30 @@ describe('Ungrouped alerts', () => { describe('Comparators trigger alerts correctly', () => { it('does not alert when counts do not reach the threshold', async () => { await callExecutor([0, Comparator.GT, 1]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); await callExecutor([0, Comparator.GT_OR_EQ, 1]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); await callExecutor([1, Comparator.LT, 0]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); await callExecutor([1, Comparator.LT_OR_EQ, 0]); - expect(getAlertState('test')).toBe(AlertStates.OK); + expect(getAlertState()).toBe(AlertStates.OK); }); it('alerts when counts reach the threshold', async () => { await callExecutor([2, Comparator.GT, 1]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); await callExecutor([1, Comparator.GT_OR_EQ, 1]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); await callExecutor([1, Comparator.LT, 2]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); await callExecutor([2, Comparator.LT_OR_EQ, 2]); - expect(getAlertState('test')).toBe(AlertStates.ALERT); + expect(getAlertState()).toBe(AlertStates.ALERT); }); }); diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts index a2fd01f8593852..85bb18e199192b 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/log_threshold_executor.ts @@ -21,8 +21,8 @@ import { InfraBackendLibs } from '../../infra_types'; import { getIntervalInSeconds } from '../../../utils/get_interval_in_seconds'; import { InfraSource } from '../../../../common/http_api/source_api'; import { decodeOrThrow } from '../../../../common/runtime_types'; +import { UNGROUPED_FACTORY_KEY } from '../common/utils'; -const UNGROUPED_FACTORY_KEY = '*'; const COMPOSITE_GROUP_SIZE = 40; const checkValueAgainstComparatorMap: { @@ -34,7 +34,7 @@ const checkValueAgainstComparatorMap: { [Comparator.LT_OR_EQ]: (a: number, b: number) => a <= b, }; -export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLibs) => +export const createLogThresholdExecutor = (libs: InfraBackendLibs) => async function ({ services, params }: AlertExecutorOptions) { const { alertInstanceFactory, savedObjectsClient, callCluster } = services; const { sources } = libs; @@ -42,7 +42,7 @@ export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLi const sourceConfiguration = await sources.getSourceConfiguration(savedObjectsClient, 'default'); const indexPattern = sourceConfiguration.configuration.logAlias; - const alertInstance = alertInstanceFactory(alertId); + const alertInstance = alertInstanceFactory(UNGROUPED_FACTORY_KEY); try { const validatedParams = decodeOrThrow(LogDocumentCountAlertParamsRT)(params); @@ -60,15 +60,13 @@ export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLi processGroupByResults( await getGroupedResults(query, callCluster), validatedParams, - alertInstanceFactory, - alertId + alertInstanceFactory ); } else { processUngroupedResults( await getUngroupedResults(query, callCluster), validatedParams, - alertInstanceFactory, - alertId + alertInstanceFactory ); } } catch (e) { @@ -83,12 +81,11 @@ export const createLogThresholdExecutor = (alertId: string, libs: InfraBackendLi const processUngroupedResults = ( results: UngroupedSearchQueryResponse, params: LogDocumentCountAlertParams, - alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'], - alertId: string + alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'] ) => { const { count, criteria } = params; - const alertInstance = alertInstanceFactory(`${alertId}-${UNGROUPED_FACTORY_KEY}`); + const alertInstance = alertInstanceFactory(UNGROUPED_FACTORY_KEY); const documentCount = results.hits.total.value; if (checkValueAgainstComparatorMap[count.comparator](documentCount, count.value)) { @@ -116,8 +113,7 @@ interface ReducedGroupByResults { const processGroupByResults = ( results: GroupedSearchQueryResponse['aggregations']['groups']['buckets'], params: LogDocumentCountAlertParams, - alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'], - alertId: string + alertInstanceFactory: AlertExecutorOptions['services']['alertInstanceFactory'] ) => { const { count, criteria } = params; @@ -128,7 +124,7 @@ const processGroupByResults = ( }, []); groupResults.forEach((group) => { - const alertInstance = alertInstanceFactory(`${alertId}-${group.name}`); + const alertInstance = alertInstanceFactory(group.name); const documentCount = group.documentCount; if (checkValueAgainstComparatorMap[count.comparator](documentCount, count.value)) { diff --git a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts index 43c298019b6325..fbbb38da53929d 100644 --- a/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts +++ b/x-pack/plugins/infra/server/lib/alerting/log_threshold/register_log_threshold_alert_type.ts @@ -3,7 +3,6 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import uuid from 'uuid'; import { i18n } from '@kbn/i18n'; import { schema } from '@kbn/config-schema'; import { PluginSetupContract } from '../../../../../alerts/server'; @@ -71,8 +70,6 @@ export async function registerLogThresholdAlertType( ); } - const alertUUID = uuid.v4(); - alertingPlugin.registerType({ id: LOG_DOCUMENT_COUNT_ALERT_TYPE_ID, name: 'Log threshold', @@ -87,7 +84,7 @@ export async function registerLogThresholdAlertType( }, defaultActionGroupId: FIRED_ACTIONS.id, actionGroups: [FIRED_ACTIONS], - executor: createLogThresholdExecutor(alertUUID, libs), + executor: createLogThresholdExecutor(libs), actionVariables: { context: [ { name: 'matchingDocuments', description: documentCountActionVariableDescription }, diff --git a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts index 7f6bf9551e2c1b..d862f70c47caec 100644 --- a/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts +++ b/x-pack/plugins/infra/server/lib/alerting/metric_threshold/lib/evaluate_alert.ts @@ -15,6 +15,7 @@ import { createAfterKeyHandler } from '../../../../utils/create_afterkey_handler import { AlertServices, AlertExecutorOptions } from '../../../../../../alerts/server'; import { getAllCompositeData } from '../../../../utils/get_all_composite_data'; import { DOCUMENT_COUNT_I18N } from '../../common/messages'; +import { UNGROUPED_FACTORY_KEY } from '../../common/utils'; import { MetricExpressionParams, Comparator, Aggregators } from '../types'; import { getElasticsearchMetricQuery } from './metric_query'; @@ -133,21 +134,21 @@ const getMetric: ( index, }); - return { '*': getValuesFromAggregations(result.aggregations, aggType) }; + return { [UNGROUPED_FACTORY_KEY]: getValuesFromAggregations(result.aggregations, aggType) }; } catch (e) { if (timeframe) { // This code should only ever be reached when previewing the alert, not executing it const causedByType = e.body?.error?.caused_by?.type; if (causedByType === 'too_many_buckets_exception') { return { - '*': { + [UNGROUPED_FACTORY_KEY]: { [TOO_MANY_BUCKETS_PREVIEW_EXCEPTION]: true, maxBuckets: e.body.error.caused_by.max_buckets, }, }; } } - return { '*': NaN }; // Trigger an Error state + return { [UNGROUPED_FACTORY_KEY]: NaN }; // Trigger an Error state } }; From a4efa1ead01ace103dff56066c0b963b68118a2f Mon Sep 17 00:00:00 2001 From: Tiago Costa Date: Tue, 14 Jul 2020 11:58:17 +0100 Subject: [PATCH 22/57] [test] Skips test preventing promotion of ES snapshot #71612 --- .../security_and_spaces/tests/create_rules_bulk.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts index 52865e43be7504..b59fd1b744e97a 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/tests/create_rules_bulk.ts @@ -29,7 +29,8 @@ export default ({ getService }: FtrProviderContext): void => { const supertest = getService('supertest'); const es = getService('es'); - describe('create_rules_bulk', () => { + // Failing ES promotion: https://github.com/elastic/kibana/issues/71612 + describe.skip('create_rules_bulk', () => { describe('validation errors', () => { it('should give a 200 even if the index does not exist as all bulks return a 200 but have an error of 409 bad request in the body', async () => { const { body } = await supertest From d8204643fe537b7e2d09301b9d36d853b4e92430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Fern=C3=A1ndez?= Date: Tue, 14 Jul 2020 13:28:35 +0200 Subject: [PATCH 23/57] [Logs UI] Refine log entry row context button (#71260) Co-authored-by: Elastic Machine --- .../log_entry_context_menu.tsx | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx index adc1ce4d8c9fd8..be140a810f1646 100644 --- a/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx +++ b/x-pack/plugins/infra/public/components/logging/log_text_stream/log_entry_context_menu.tsx @@ -6,7 +6,13 @@ import React, { useMemo } from 'react'; import { i18n } from '@kbn/i18n'; -import { EuiButtonIcon, EuiPopover, EuiContextMenuPanel, EuiContextMenuItem } from '@elastic/eui'; +import { + EuiButton, + EuiIcon, + EuiPopover, + EuiContextMenuPanel, + EuiContextMenuItem, +} from '@elastic/eui'; import { euiStyled } from '../../../../../observability/public'; import { LogEntryColumnContent } from './log_entry_column'; @@ -50,12 +56,15 @@ export const LogEntryContextMenu: React.FC = ({ const button = ( - + style={{ minWidth: 'auto' }} + > + + ); @@ -88,8 +97,5 @@ const AbsoluteWrapper = euiStyled.div` `; const ButtonWrapper = euiStyled.div` - background: ${(props) => props.theme.eui.euiColorPrimary}; - border-radius: 50%; - padding: 4px; - transform: translateY(-6px); + transform: translate(-6px, -6px); `; From 262e0754ff5b4be301b00992496fd9871deb9ed3 Mon Sep 17 00:00:00 2001 From: Walter Rafelsberger Date: Tue, 14 Jul 2020 13:37:36 +0200 Subject: [PATCH 24/57] [ML] Kibana API endpoint for histogram chart data (#70976) - Introduces dedicated Kibana API endpoints as part of ML and transform plugin API endpoints and moves the logic to query and transform the required data from client to server. - Adds support for sampling to retrieve the data for the field histograms. For now this is not configurable by the end user and is hard coded to 5000. This is to have a first iteration of this functionality in for 7.9 and protect users when querying large clusters. The button to enable the histogram charts now includes a tooltip that mentions the sampler. --- .../ml/common/constants/field_histograms.ts | 8 + .../components/data_grid/data_grid.tsx | 41 ++- .../application/components/data_grid/index.ts | 2 +- .../data_grid/use_column_chart.test.ts | 18 ++ .../components/data_grid/use_column_chart.tsx | 186 +----------- .../hooks/use_index_data.ts | 24 +- .../use_exploration_results.ts | 28 +- .../outlier_exploration/use_outlier_data.ts | 30 +- .../index_based/common/index.ts | 2 +- .../index_based/common/request.ts | 7 + .../index_based/data_loader/data_loader.ts | 33 ++- .../datavisualizer/index_based/page.tsx | 8 +- .../services/ml_api_service/index.ts | 29 +- .../models/data_visualizer/data_visualizer.ts | 267 +++++++++++++++++- .../ml/server/models/data_visualizer/index.ts | 2 +- .../ml/server/routes/data_visualizer.ts | 61 +++- .../routes/schemas/data_visualizer_schema.ts | 9 + x-pack/plugins/ml/server/shared.ts | 1 + .../transform/public/app/hooks/use_api.ts | 26 ++ .../public/app/hooks/use_index_data.ts | 15 +- .../transform/public/shared_imports.ts | 2 +- .../server/routes/api/field_histograms.ts | 50 ++++ .../transform/server/routes/api/schema.ts | 18 ++ .../plugins/transform/server/routes/index.ts | 2 + .../transform/server/shared_imports.ts | 7 + .../data_visualizer/get_field_histograms.ts | 122 ++++++++ .../outlier_detection_creation.ts | 22 ++ .../ml/data_frame_analytics_creation.ts | 52 ++++ 28 files changed, 822 insertions(+), 250 deletions(-) create mode 100644 x-pack/plugins/ml/common/constants/field_histograms.ts create mode 100644 x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.ts create mode 100644 x-pack/plugins/transform/server/routes/api/field_histograms.ts create mode 100644 x-pack/plugins/transform/server/shared_imports.ts create mode 100644 x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts diff --git a/x-pack/plugins/ml/common/constants/field_histograms.ts b/x-pack/plugins/ml/common/constants/field_histograms.ts new file mode 100644 index 00000000000000..5c86c00ac666f1 --- /dev/null +++ b/x-pack/plugins/ml/common/constants/field_histograms.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +// Default sampler shard size used for field histograms +export const DEFAULT_SAMPLER_SHARD_SIZE = 5000; diff --git a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx index 9af7a869e0e568..d4be2eab13d26b 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/data_grid.tsx @@ -20,10 +20,13 @@ import { EuiFlexItem, EuiSpacer, EuiTitle, + EuiToolTip, } from '@elastic/eui'; import { CoreSetup } from 'src/core/public'; +import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../common/constants/field_histograms'; + import { INDEX_STATUS } from '../../data_frame_analytics/common'; import { euiDataGridStyle, euiDataGridToolbarSettings } from './common'; @@ -193,21 +196,31 @@ export const DataGrid: FC = memo( ...(chartsButtonVisible ? { additionalControls: ( - - {i18n.translate('xpack.ml.dataGrid.histogramButtonText', { - defaultMessage: 'Histogram charts', + + > + + {i18n.translate('xpack.ml.dataGrid.histogramButtonText', { + defaultMessage: 'Histogram charts', + })} + + ), } : {}), diff --git a/x-pack/plugins/ml/public/application/components/data_grid/index.ts b/x-pack/plugins/ml/public/application/components/data_grid/index.ts index 80bc6b861f7425..4bbd3595e5a7e4 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/index.ts +++ b/x-pack/plugins/ml/public/application/components/data_grid/index.ts @@ -12,7 +12,7 @@ export { showDataGridColumnChartErrorMessageToast, useRenderCellValue, } from './common'; -export { fetchChartsData, ChartData } from './use_column_chart'; +export { getFieldType, ChartData } from './use_column_chart'; export { useDataGrid } from './use_data_grid'; export { DataGrid } from './data_grid'; export { diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.ts b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.ts new file mode 100644 index 00000000000000..1b35ef238d09e7 --- /dev/null +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.test.ts @@ -0,0 +1,18 @@ +/* + * 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 { getFieldType } from './use_column_chart'; + +describe('getFieldType()', () => { + it('should return the Kibana field type for a given EUI data grid schema', () => { + expect(getFieldType('text')).toBe('string'); + expect(getFieldType('datetime')).toBe('date'); + expect(getFieldType('numeric')).toBe('number'); + expect(getFieldType('boolean')).toBe('boolean'); + expect(getFieldType('json')).toBe('object'); + expect(getFieldType('non-aggregatable')).toBe(undefined); + }); +}); diff --git a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx index 6b207a999eb52a..a762c44e243bf0 100644 --- a/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx +++ b/x-pack/plugins/ml/public/application/components/data_grid/use_column_chart.tsx @@ -16,8 +16,6 @@ import { i18n } from '@kbn/i18n'; import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public'; -import { stringHash } from '../../../../common/util/string_utils'; - import { NON_AGGREGATABLE } from './common'; export const hoveredRow$ = new BehaviorSubject(null); @@ -40,7 +38,7 @@ const getXScaleType = (kbnFieldType: KBN_FIELD_TYPES | undefined): XScaleType => } }; -const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | undefined => { +export const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | undefined => { if (schema === NON_AGGREGATABLE) { return undefined; } @@ -67,188 +65,6 @@ const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | un return fieldType; }; -interface NumericColumnStats { - interval: number; - min: number; - max: number; -} -type NumericColumnStatsMap = Record; -const getAggIntervals = async ( - indexPatternTitle: string, - esSearch: (payload: any) => Promise, - query: any, - columnTypes: EuiDataGridColumn[] -): Promise => { - const numericColumns = columnTypes.filter((cT) => { - const fieldType = getFieldType(cT.schema); - return fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE; - }); - - if (numericColumns.length === 0) { - return {}; - } - - const minMaxAggs = numericColumns.reduce((aggs, c) => { - const id = stringHash(c.id); - aggs[id] = { - stats: { - field: c.id, - }, - }; - return aggs; - }, {} as Record); - - const respStats = await esSearch({ - index: indexPatternTitle, - size: 0, - body: { - query, - aggs: minMaxAggs, - size: 0, - }, - }); - - return Object.keys(respStats.aggregations).reduce((p, aggName) => { - const stats = [respStats.aggregations[aggName].min, respStats.aggregations[aggName].max]; - if (!stats.includes(null)) { - const delta = respStats.aggregations[aggName].max - respStats.aggregations[aggName].min; - - let aggInterval = 1; - - if (delta > MAX_CHART_COLUMNS) { - aggInterval = Math.round(delta / MAX_CHART_COLUMNS); - } - - if (delta <= 1) { - aggInterval = delta / MAX_CHART_COLUMNS; - } - - p[aggName] = { interval: aggInterval, min: stats[0], max: stats[1] }; - } - - return p; - }, {} as NumericColumnStatsMap); -}; - -interface AggHistogram { - histogram: { - field: string; - interval: number; - }; -} - -interface AggCardinality { - cardinality: { - field: string; - }; -} - -interface AggTerms { - terms: { - field: string; - size: number; - }; -} - -type ChartRequestAgg = AggHistogram | AggCardinality | AggTerms; - -export const fetchChartsData = async ( - indexPatternTitle: string, - esSearch: (payload: any) => Promise, - query: any, - columnTypes: EuiDataGridColumn[] -): Promise => { - const aggIntervals = await getAggIntervals(indexPatternTitle, esSearch, query, columnTypes); - - const chartDataAggs = columnTypes.reduce((aggs, c) => { - const fieldType = getFieldType(c.schema); - const id = stringHash(c.id); - if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { - if (aggIntervals[id] !== undefined) { - aggs[`${id}_histogram`] = { - histogram: { - field: c.id, - interval: aggIntervals[id].interval !== 0 ? aggIntervals[id].interval : 1, - }, - }; - } - } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { - if (fieldType === KBN_FIELD_TYPES.STRING) { - aggs[`${id}_cardinality`] = { - cardinality: { - field: c.id, - }, - }; - } - aggs[`${id}_terms`] = { - terms: { - field: c.id, - size: MAX_CHART_COLUMNS, - }, - }; - } - return aggs; - }, {} as Record); - - if (Object.keys(chartDataAggs).length === 0) { - return []; - } - - const respChartsData = await esSearch({ - index: indexPatternTitle, - size: 0, - body: { - query, - aggs: chartDataAggs, - size: 0, - }, - }); - - const chartsData: ChartData[] = columnTypes.map( - (c): ChartData => { - const fieldType = getFieldType(c.schema); - const id = stringHash(c.id); - - if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { - if (aggIntervals[id] === undefined) { - return { - type: 'numeric', - data: [], - interval: 0, - stats: [0, 0], - id: c.id, - }; - } - - return { - data: respChartsData.aggregations[`${id}_histogram`].buckets, - interval: aggIntervals[id].interval, - stats: [aggIntervals[id].min, aggIntervals[id].max], - type: 'numeric', - id: c.id, - }; - } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { - return { - type: fieldType === KBN_FIELD_TYPES.STRING ? 'ordinal' : 'boolean', - cardinality: - fieldType === KBN_FIELD_TYPES.STRING - ? respChartsData.aggregations[`${id}_cardinality`].value - : 2, - data: respChartsData.aggregations[`${id}_terms`].buckets, - id: c.id, - }; - } - - return { - type: 'unsupported', - id: c.id, - }; - } - ); - - return chartsData; -}; - interface NumericDataItem { key: number; key_as_string?: string; diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts index ee0e5c1955eadd..2cecffc9932570 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_creation/hooks/use_index_data.ts @@ -4,15 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; import { CoreSetup } from 'src/core/public'; import { IndexPattern } from '../../../../../../../../../src/plugins/data/public'; + +import { DataLoader } from '../../../../datavisualizer/index_based/data_loader'; + import { - fetchChartsData, + getFieldType, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, showDataGridColumnChartErrorMessageToast, @@ -103,13 +106,20 @@ export const useIndexData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [indexPattern.title, JSON.stringify([query, pagination, sortingColumns])]); + const dataLoader = useMemo(() => new DataLoader(indexPattern, toastNotifications), [ + indexPattern, + ]); + const fetchColumnChartsData = async function () { try { - const columnChartsData = await fetchChartsData( - indexPattern.title, - ml.esSearch, - query, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + const columnChartsData = await dataLoader.loadFieldHistograms( + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + query ); dataGrid.setColumnCharts(columnChartsData); } catch (e) { diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts index 796670f6a864df..98dd40986e32b6 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/exploration_results_table/use_exploration_results.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; @@ -12,16 +12,17 @@ import { CoreSetup } from 'src/core/public'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { DataLoader } from '../../../../../datavisualizer/index_based/data_loader'; + import { - fetchChartsData, getDataGridSchemasFromFieldTypes, + getFieldType, showDataGridColumnChartErrorMessageToast, useDataGrid, useRenderCellValue, UseIndexDataReturnType, } from '../../../../../components/data_grid'; import { SavedSearchQuery } from '../../../../../contexts/ml'; -import { ml } from '../../../../../services/ml_api_service'; import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common'; import { @@ -72,14 +73,23 @@ export const useExplorationResults = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]); + const dataLoader = useMemo( + () => + indexPattern !== undefined ? new DataLoader(indexPattern, toastNotifications) : undefined, + [indexPattern] + ); + const fetchColumnChartsData = async function () { try { - if (jobConfig !== undefined) { - const columnChartsData = await fetchChartsData( - jobConfig.dest.index, - ml.esSearch, - searchQuery, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + if (jobConfig !== undefined && dataLoader !== undefined) { + const columnChartsData = await dataLoader.loadFieldHistograms( + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + searchQuery ); dataGrid.setColumnCharts(columnChartsData); } diff --git a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts index beb6836bf801fa..90294a09c0adc3 100644 --- a/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts +++ b/x-pack/plugins/ml/public/application/data_frame_analytics/pages/analytics_exploration/components/outlier_exploration/use_outlier_data.ts @@ -4,19 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public'; +import { DataLoader } from '../../../../../datavisualizer/index_based/data_loader'; + import { useColorRange, COLOR_RANGE, COLOR_RANGE_SCALE, } from '../../../../../components/color_range_legend'; import { - fetchChartsData, + getFieldType, getDataGridSchemasFromFieldTypes, showDataGridColumnChartErrorMessageToast, useDataGrid, @@ -24,7 +26,6 @@ import { UseIndexDataReturnType, } from '../../../../../components/data_grid'; import { SavedSearchQuery } from '../../../../../contexts/ml'; -import { ml } from '../../../../../services/ml_api_service'; import { getToastNotifications } from '../../../../../util/dependency_cache'; import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common'; @@ -79,14 +80,25 @@ export const useOutlierData = ( // eslint-disable-next-line react-hooks/exhaustive-deps }, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]); + const dataLoader = useMemo( + () => + indexPattern !== undefined + ? new DataLoader(indexPattern, getToastNotifications()) + : undefined, + [indexPattern] + ); + const fetchColumnChartsData = async function () { try { - if (jobConfig !== undefined) { - const columnChartsData = await fetchChartsData( - jobConfig.dest.index, - ml.esSearch, - searchQuery, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + if (jobConfig !== undefined && dataLoader !== undefined) { + const columnChartsData = await dataLoader.loadFieldHistograms( + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + searchQuery ); dataGrid.setColumnCharts(columnChartsData); } diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts index 5618f701e4c5fd..50278c300d1032 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/index.ts @@ -5,4 +5,4 @@ */ export { FieldVisConfig } from './field_vis_config'; -export { FieldRequestConfig } from './request'; +export { FieldHistogramRequestConfig, FieldRequestConfig } from './request'; diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts index 9a886cbc899c24..fd4888b8729c18 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/common/request.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { KBN_FIELD_TYPES } from '../../../../../../../../src/plugins/data/public'; + import { ML_JOB_FIELD_TYPES } from '../../../../../common/constants/field_types'; export interface FieldRequestConfig { @@ -11,3 +13,8 @@ export interface FieldRequestConfig { type: ML_JOB_FIELD_TYPES; cardinality: number; } + +export interface FieldHistogramRequestConfig { + fieldName: string; + type?: KBN_FIELD_TYPES; +} diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts b/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts index a08821c65bfe79..34f86ffa187883 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/data_loader/data_loader.ts @@ -6,15 +6,17 @@ import { i18n } from '@kbn/i18n'; -import { getToastNotifications } from '../../../util/dependency_cache'; +import { CoreSetup } from 'src/core/public'; + import { IndexPattern } from '../../../../../../../../src/plugins/data/public'; import { SavedSearchQuery } from '../../../contexts/ml'; import { OMIT_FIELDS } from '../../../../../common/constants/field_types'; import { IndexPatternTitle } from '../../../../../common/types/kibana'; +import { DEFAULT_SAMPLER_SHARD_SIZE } from '../../../../../common/constants/field_histograms'; import { ml } from '../../../services/ml_api_service'; -import { FieldRequestConfig } from '../common'; +import { FieldHistogramRequestConfig, FieldRequestConfig } from '../common'; // Maximum number of examples to obtain for text type fields. const MAX_EXAMPLES_DEFAULT: number = 10; @@ -23,10 +25,15 @@ export class DataLoader { private _indexPattern: IndexPattern; private _indexPatternTitle: IndexPatternTitle = ''; private _maxExamples: number = MAX_EXAMPLES_DEFAULT; + private _toastNotifications: CoreSetup['notifications']['toasts']; - constructor(indexPattern: IndexPattern, kibanaConfig: any) { + constructor( + indexPattern: IndexPattern, + toastNotifications: CoreSetup['notifications']['toasts'] + ) { this._indexPattern = indexPattern; this._indexPatternTitle = indexPattern.title; + this._toastNotifications = toastNotifications; } async loadOverallData( @@ -90,10 +97,24 @@ export class DataLoader { return stats; } + async loadFieldHistograms( + fields: FieldHistogramRequestConfig[], + query: string | SavedSearchQuery, + samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE + ): Promise { + const stats = await ml.getVisualizerFieldHistograms({ + indexPatternTitle: this._indexPatternTitle, + query, + fields, + samplerShardSize, + }); + + return stats; + } + displayError(err: any) { - const toastNotifications = getToastNotifications(); if (err.statusCode === 500) { - toastNotifications.addDanger( + this._toastNotifications.addDanger( i18n.translate('xpack.ml.datavisualizer.dataLoader.internalServerErrorMessage', { defaultMessage: 'Error loading data in index {index}. {message}. ' + @@ -105,7 +126,7 @@ export class DataLoader { }) ); } else { - toastNotifications.addDanger( + this._toastNotifications.addDanger( i18n.translate('xpack.ml.datavisualizer.page.errorLoadingDataMessage', { defaultMessage: 'Error loading data in index {index}. {message}', values: { diff --git a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx index 97b4043c9fd644..3c332d305d7e99 100644 --- a/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx +++ b/x-pack/plugins/ml/public/application/datavisualizer/index_based/page.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { FC, Fragment, useEffect, useState } from 'react'; +import React, { FC, Fragment, useEffect, useMemo, useState } from 'react'; import { merge } from 'rxjs'; import { i18n } from '@kbn/i18n'; @@ -43,6 +43,7 @@ import { kbnTypeToMLJobType } from '../../util/field_types_utils'; import { useTimefilter } from '../../contexts/kibana'; import { timeBasedIndexCheck, getQueryFromSavedSearch } from '../../util/index_utils'; import { getTimeBucketsFromCache } from '../../util/time_buckets'; +import { getToastNotifications } from '../../util/dependency_cache'; import { useUrlState } from '../../util/url_state'; import { FieldRequestConfig, FieldVisConfig } from './common'; import { ActionsPanel } from './components/actions_panel'; @@ -107,7 +108,10 @@ export const Page: FC = () => { autoRefreshSelector: true, }); - const dataLoader = new DataLoader(currentIndexPattern, kibanaConfig); + const dataLoader = useMemo(() => new DataLoader(currentIndexPattern, getToastNotifications()), [ + currentIndexPattern, + ]); + const [globalState, setGlobalState] = useUrlState('_g'); useEffect(() => { if (globalState?.time !== undefined) { diff --git a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts index d1b6f95f32bed5..599e4d4bb8a10e 100644 --- a/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts +++ b/x-pack/plugins/ml/public/application/services/ml_api_service/index.ts @@ -27,7 +27,10 @@ import { ModelSnapshot, } from '../../../../common/types/anomaly_detection_jobs'; import { ES_AGGREGATION } from '../../../../common/constants/aggregation_types'; -import { FieldRequestConfig } from '../../datavisualizer/index_based/common'; +import { + FieldHistogramRequestConfig, + FieldRequestConfig, +} from '../../datavisualizer/index_based/common'; import { DataRecognizerConfigResponse, Module } from '../../../../common/types/modules'; import { getHttp } from '../../util/dependency_cache'; @@ -494,6 +497,30 @@ export function mlApiServicesProvider(httpService: HttpService) { }); }, + getVisualizerFieldHistograms({ + indexPatternTitle, + query, + fields, + samplerShardSize, + }: { + indexPatternTitle: string; + query: any; + fields: FieldHistogramRequestConfig[]; + samplerShardSize?: number; + }) { + const body = JSON.stringify({ + query, + fields, + samplerShardSize, + }); + + return httpService.http({ + path: `${basePath()}/data_visualizer/get_field_histograms/${indexPatternTitle}`, + method: 'POST', + body, + }); + }, + getVisualizerOverallStats({ indexPatternTitle, query, diff --git a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts index d58c797b446db6..d1a4a0b585fbb9 100644 --- a/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts +++ b/x-pack/plugins/ml/server/models/data_visualizer/data_visualizer.ts @@ -4,10 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { LegacyCallAPIOptions, LegacyAPICaller } from 'kibana/server'; +import { LegacyAPICaller } from 'kibana/server'; import _ from 'lodash'; +import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/server'; import { ML_JOB_FIELD_TYPES } from '../../../common/constants/field_types'; import { getSafeAggregationName } from '../../../common/util/job_utils'; +import { stringHash } from '../../../common/util/string_utils'; import { buildBaseFilterCriteria, buildSamplerAggregation, @@ -19,6 +21,8 @@ const SAMPLER_TOP_TERMS_SHARD_SIZE = 5000; const AGGREGATABLE_EXISTS_REQUEST_BATCH_SIZE = 200; const FIELDS_REQUEST_BATCH_SIZE = 10; +const MAX_CHART_COLUMNS = 20; + interface FieldData { fieldName: string; existsInDocs: boolean; @@ -35,6 +39,11 @@ export interface Field { cardinality: number; } +export interface HistogramField { + fieldName: string; + type: string; +} + interface Distribution { percentiles: any[]; minPercentile: number; @@ -98,6 +107,70 @@ interface FieldExamples { examples: any[]; } +interface NumericColumnStats { + interval: number; + min: number; + max: number; +} +type NumericColumnStatsMap = Record; + +interface AggHistogram { + histogram: { + field: string; + interval: number; + }; +} + +interface AggCardinality { + cardinality: { + field: string; + }; +} + +interface AggTerms { + terms: { + field: string; + size: number; + }; +} + +interface NumericDataItem { + key: number; + key_as_string?: string; + doc_count: number; +} + +interface NumericChartData { + data: NumericDataItem[]; + id: string; + interval: number; + stats: [number, number]; + type: 'numeric'; +} + +interface OrdinalDataItem { + key: string; + key_as_string?: string; + doc_count: number; +} + +interface OrdinalChartData { + type: 'ordinal' | 'boolean'; + cardinality: number; + data: OrdinalDataItem[]; + id: string; +} + +interface UnsupportedChartData { + id: string; + type: 'unsupported'; +} + +type ChartRequestAgg = AggHistogram | AggCardinality | AggTerms; + +// type ChartDataItem = NumericDataItem | OrdinalDataItem; +type ChartData = NumericChartData | OrdinalChartData | UnsupportedChartData; + type BatchStats = | NumericFieldStats | StringFieldStats @@ -106,12 +179,176 @@ type BatchStats = | DocumentCountStats | FieldExamples; +const getAggIntervals = async ( + callAsCurrentUser: LegacyAPICaller, + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number +): Promise => { + const numericColumns = fields.filter((field) => { + return field.type === KBN_FIELD_TYPES.NUMBER || field.type === KBN_FIELD_TYPES.DATE; + }); + + if (numericColumns.length === 0) { + return {}; + } + + const minMaxAggs = numericColumns.reduce((aggs, c) => { + const id = stringHash(c.fieldName); + aggs[id] = { + stats: { + field: c.fieldName, + }, + }; + return aggs; + }, {} as Record); + + const respStats = await callAsCurrentUser('search', { + index: indexPatternTitle, + size: 0, + body: { + query, + aggs: buildSamplerAggregation(minMaxAggs, samplerShardSize), + size: 0, + }, + }); + + const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); + const aggregations = + aggsPath.length > 0 ? _.get(respStats.aggregations, aggsPath) : respStats.aggregations; + + return Object.keys(aggregations).reduce((p, aggName) => { + const stats = [aggregations[aggName].min, aggregations[aggName].max]; + if (!stats.includes(null)) { + const delta = aggregations[aggName].max - aggregations[aggName].min; + + let aggInterval = 1; + + if (delta > MAX_CHART_COLUMNS || delta <= 1) { + aggInterval = delta / (MAX_CHART_COLUMNS - 1); + } + + p[aggName] = { interval: aggInterval, min: stats[0], max: stats[1] }; + } + + return p; + }, {} as NumericColumnStatsMap); +}; + +// export for re-use by transforms plugin +export const getHistogramsForFields = async ( + callAsCurrentUser: LegacyAPICaller, + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number +) => { + const aggIntervals = await getAggIntervals( + callAsCurrentUser, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + + const chartDataAggs = fields.reduce((aggs, field) => { + const fieldName = field.fieldName; + const fieldType = field.type; + const id = stringHash(fieldName); + if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { + if (aggIntervals[id] !== undefined) { + aggs[`${id}_histogram`] = { + histogram: { + field: fieldName, + interval: aggIntervals[id].interval !== 0 ? aggIntervals[id].interval : 1, + }, + }; + } + } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { + if (fieldType === KBN_FIELD_TYPES.STRING) { + aggs[`${id}_cardinality`] = { + cardinality: { + field: fieldName, + }, + }; + } + aggs[`${id}_terms`] = { + terms: { + field: fieldName, + size: MAX_CHART_COLUMNS, + }, + }; + } + return aggs; + }, {} as Record); + + if (Object.keys(chartDataAggs).length === 0) { + return []; + } + + const respChartsData = await callAsCurrentUser('search', { + index: indexPatternTitle, + size: 0, + body: { + query, + aggs: buildSamplerAggregation(chartDataAggs, samplerShardSize), + size: 0, + }, + }); + + const aggsPath = getSamplerAggregationsResponsePath(samplerShardSize); + const aggregations = + aggsPath.length > 0 + ? _.get(respChartsData.aggregations, aggsPath) + : respChartsData.aggregations; + + const chartsData: ChartData[] = fields.map( + (field): ChartData => { + const fieldName = field.fieldName; + const fieldType = field.type; + const id = stringHash(field.fieldName); + + if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) { + if (aggIntervals[id] === undefined) { + return { + type: 'numeric', + data: [], + interval: 0, + stats: [0, 0], + id: fieldName, + }; + } + + return { + data: aggregations[`${id}_histogram`].buckets, + interval: aggIntervals[id].interval, + stats: [aggIntervals[id].min, aggIntervals[id].max], + type: 'numeric', + id: fieldName, + }; + } else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) { + return { + type: fieldType === KBN_FIELD_TYPES.STRING ? 'ordinal' : 'boolean', + cardinality: + fieldType === KBN_FIELD_TYPES.STRING ? aggregations[`${id}_cardinality`].value : 2, + data: aggregations[`${id}_terms`].buckets, + id: fieldName, + }; + } + + return { + type: 'unsupported', + id: fieldName, + }; + } + ); + + return chartsData; +}; + export class DataVisualizer { - callAsCurrentUser: ( - endpoint: string, - clientParams: Record, - options?: LegacyCallAPIOptions - ) => Promise; + callAsCurrentUser: LegacyAPICaller; constructor(callAsCurrentUser: LegacyAPICaller) { this.callAsCurrentUser = callAsCurrentUser; @@ -200,6 +437,24 @@ export class DataVisualizer { return stats; } + // Obtains binned histograms for supplied list of fields. The statistics for each field in the + // returned array depend on the type of the field (keyword, number, date etc). + // Sampling will be used if supplied samplerShardSize > 0. + async getHistogramsForFields( + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number + ): Promise { + return await getHistogramsForFields( + this.callAsCurrentUser, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + } + // Obtains statistics for supplied list of fields. The statistics for each field in the // returned array depend on the type of the field (keyword, number, date etc). // Sampling will be used if supplied samplerShardSize > 0. diff --git a/x-pack/plugins/ml/server/models/data_visualizer/index.ts b/x-pack/plugins/ml/server/models/data_visualizer/index.ts index ed44e9b12e1d14..ca1df0fe8300c9 100644 --- a/x-pack/plugins/ml/server/models/data_visualizer/index.ts +++ b/x-pack/plugins/ml/server/models/data_visualizer/index.ts @@ -4,4 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export { DataVisualizer } from './data_visualizer'; +export { getHistogramsForFields, DataVisualizer } from './data_visualizer'; diff --git a/x-pack/plugins/ml/server/routes/data_visualizer.ts b/x-pack/plugins/ml/server/routes/data_visualizer.ts index 04008a896a1a22..9dd010e105b6e7 100644 --- a/x-pack/plugins/ml/server/routes/data_visualizer.ts +++ b/x-pack/plugins/ml/server/routes/data_visualizer.ts @@ -7,8 +7,9 @@ import { RequestHandlerContext } from 'kibana/server'; import { wrapError } from '../client/error_wrapper'; import { DataVisualizer } from '../models/data_visualizer'; -import { Field } from '../models/data_visualizer/data_visualizer'; +import { Field, HistogramField } from '../models/data_visualizer/data_visualizer'; import { + dataVisualizerFieldHistogramsSchema, dataVisualizerFieldStatsSchema, dataVisualizerOverallStatsSchema, indexPatternTitleSchema, @@ -65,10 +66,68 @@ function getStatsForFields( ); } +function getHistogramsForFields( + context: RequestHandlerContext, + indexPatternTitle: string, + query: any, + fields: HistogramField[], + samplerShardSize: number +) { + const dv = new DataVisualizer(context.ml!.mlClient.callAsCurrentUser); + return dv.getHistogramsForFields(indexPatternTitle, query, fields, samplerShardSize); +} + /** * Routes for the index data visualizer. */ export function dataVisualizerRoutes({ router, mlLicense }: RouteInitialization) { + /** + * @apiGroup DataVisualizer + * + * @api {post} /api/ml/data_visualizer/get_field_stats/:indexPatternTitle Get histograms for fields + * @apiName GetHistogramsForFields + * @apiDescription Returns the histograms on a list fields in the specified index pattern. + * + * @apiSchema (params) indexPatternTitleSchema + * @apiSchema (body) dataVisualizerFieldHistogramsSchema + * + * @apiSuccess {Object} fieldName histograms by field, keyed on the name of the field. + */ + router.post( + { + path: '/api/ml/data_visualizer/get_field_histograms/{indexPatternTitle}', + validate: { + params: indexPatternTitleSchema, + body: dataVisualizerFieldHistogramsSchema, + }, + options: { + tags: ['access:ml:canAccessML'], + }, + }, + mlLicense.basicLicenseAPIGuard(async (context, request, response) => { + try { + const { + params: { indexPatternTitle }, + body: { query, fields, samplerShardSize }, + } = request; + + const results = await getHistogramsForFields( + context, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + + return response.ok({ + body: results, + }); + } catch (e) { + return response.customError(wrapError(e)); + } + }) + ); + /** * @apiGroup DataVisualizer * diff --git a/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts b/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts index b2d665954bd4dc..24e45514e1efce 100644 --- a/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts +++ b/x-pack/plugins/ml/server/routes/schemas/data_visualizer_schema.ts @@ -11,6 +11,15 @@ export const indexPatternTitleSchema = schema.object({ indexPatternTitle: schema.string(), }); +export const dataVisualizerFieldHistogramsSchema = schema.object({ + /** Query to match documents in the index. */ + query: schema.any(), + /** The fields to return histogram data. */ + fields: schema.arrayOf(schema.any()), + /** Number of documents to be collected in the sample processed on each shard, or -1 for no sampling. */ + samplerShardSize: schema.number(), +}); + export const dataVisualizerFieldStatsSchema = schema.object({ /** Query to match documents in the index. */ query: schema.any(), diff --git a/x-pack/plugins/ml/server/shared.ts b/x-pack/plugins/ml/server/shared.ts index 3fca8ea1ba0478..100433b23f7d13 100644 --- a/x-pack/plugins/ml/server/shared.ts +++ b/x-pack/plugins/ml/server/shared.ts @@ -8,3 +8,4 @@ export * from '../common/types/anomalies'; export * from '../common/types/anomaly_detection_jobs'; export * from './lib/capabilities/errors'; export { ModuleSetupPayload } from './shared_services/providers/modules'; +export { getHistogramsForFields } from './models/data_visualizer/'; diff --git a/x-pack/plugins/transform/public/app/hooks/use_api.ts b/x-pack/plugins/transform/public/app/hooks/use_api.ts index 56528370a3ab9a..1d2752b9e939dc 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_api.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_api.ts @@ -5,6 +5,9 @@ */ import { useMemo } from 'react'; + +import { KBN_FIELD_TYPES } from '../../../../../../src/plugins/data/public'; + import { TransformId, TransformEndpointRequest, @@ -17,6 +20,15 @@ import { useAppDependencies } from '../app_dependencies'; import { GetTransformsResponse, PreviewRequestBody } from '../common'; import { EsIndex } from './use_api_types'; +import { SavedSearchQuery } from './use_search_items'; + +// Default sampler shard size used for field histograms +export const DEFAULT_SAMPLER_SHARD_SIZE = 5000; + +export interface FieldHistogramRequestConfig { + fieldName: string; + type?: KBN_FIELD_TYPES; +} export const useApi = () => { const { http } = useAppDependencies(); @@ -85,6 +97,20 @@ export const useApi = () => { getIndices(): Promise { return http.get(`/api/index_management/indices`); }, + getHistogramsForFields( + indexPatternTitle: string, + fields: FieldHistogramRequestConfig[], + query: string | SavedSearchQuery, + samplerShardSize = DEFAULT_SAMPLER_SHARD_SIZE + ) { + return http.post(`${API_BASE_PATH}field_histograms/${indexPatternTitle}`, { + body: JSON.stringify({ + query, + fields, + samplerShardSize, + }), + }); + }, }), [http] ); diff --git a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts index c821c183ad370c..ad5850f26be2e2 100644 --- a/x-pack/plugins/transform/public/app/hooks/use_index_data.ts +++ b/x-pack/plugins/transform/public/app/hooks/use_index_data.ts @@ -9,7 +9,7 @@ import { useEffect } from 'react'; import { EuiDataGridColumn } from '@elastic/eui'; import { - fetchChartsData, + getFieldType, getDataGridSchemaFromKibanaFieldType, getFieldsFromKibanaIndexPattern, getErrorMessage, @@ -107,13 +107,16 @@ export const useIndexData = ( const fetchColumnChartsData = async function () { try { - const columnChartsData = await fetchChartsData( + const columnChartsData = await api.getHistogramsForFields( indexPattern.title, - api.esSearch, - isDefaultQuery(query) ? matchAllQuery : query, - columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + columns + .filter((cT) => dataGrid.visibleColumns.includes(cT.id)) + .map((cT) => ({ + fieldName: cT.id, + type: getFieldType(cT.schema), + })), + isDefaultQuery(query) ? matchAllQuery : query ); - setColumnCharts(columnChartsData); } catch (e) { showDataGridColumnChartErrorMessageToast(e, toastNotifications); diff --git a/x-pack/plugins/transform/public/shared_imports.ts b/x-pack/plugins/transform/public/shared_imports.ts index e0bbcd0b5d9db7..abbc39dd6c7287 100644 --- a/x-pack/plugins/transform/public/shared_imports.ts +++ b/x-pack/plugins/transform/public/shared_imports.ts @@ -14,7 +14,7 @@ export { } from '../../../../src/plugins/es_ui_shared/public'; export { - fetchChartsData, + getFieldType, getErrorMessage, extractErrorMessage, formatHumanReadableDateTimeSeconds, diff --git a/x-pack/plugins/transform/server/routes/api/field_histograms.ts b/x-pack/plugins/transform/server/routes/api/field_histograms.ts new file mode 100644 index 00000000000000..d602e49338846a --- /dev/null +++ b/x-pack/plugins/transform/server/routes/api/field_histograms.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +/* + * 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 { wrapEsError } from '../../../../../legacy/server/lib/create_router/error_wrappers'; + +import { getHistogramsForFields } from '../../shared_imports'; +import { RouteDependencies } from '../../types'; + +import { addBasePath } from '../index'; + +import { wrapError } from './error_utils'; +import { fieldHistogramsSchema, indexPatternTitleSchema, IndexPatternTitleSchema } from './schema'; + +export function registerFieldHistogramsRoutes({ router, license }: RouteDependencies) { + router.post( + { + path: addBasePath('field_histograms/{indexPatternTitle}'), + validate: { + params: indexPatternTitleSchema, + body: fieldHistogramsSchema, + }, + }, + license.guardApiRoute(async (ctx, req, res) => { + const { indexPatternTitle } = req.params as IndexPatternTitleSchema; + const { query, fields, samplerShardSize } = req.body; + + try { + const resp = await getHistogramsForFields( + ctx.transform!.dataClient.callAsCurrentUser, + indexPatternTitle, + query, + fields, + samplerShardSize + ); + + return res.ok({ body: resp }); + } catch (e) { + return res.customError(wrapError(wrapEsError(e))); + } + }) + ); +} diff --git a/x-pack/plugins/transform/server/routes/api/schema.ts b/x-pack/plugins/transform/server/routes/api/schema.ts index 7da3f1ccfe55e3..8aadef81b221b9 100644 --- a/x-pack/plugins/transform/server/routes/api/schema.ts +++ b/x-pack/plugins/transform/server/routes/api/schema.ts @@ -5,6 +5,24 @@ */ import { schema } from '@kbn/config-schema'; +export const fieldHistogramsSchema = schema.object({ + /** Query to match documents in the index. */ + query: schema.any(), + /** The fields to return histogram data. */ + fields: schema.arrayOf(schema.any()), + /** Number of documents to be collected in the sample processed on each shard, or -1 for no sampling. */ + samplerShardSize: schema.number(), +}); + +export const indexPatternTitleSchema = schema.object({ + /** Title of the index pattern for which to return stats. */ + indexPatternTitle: schema.string(), +}); + +export interface IndexPatternTitleSchema { + indexPatternTitle: string; +} + export const schemaTransformId = { params: schema.object({ transformId: schema.string(), diff --git a/x-pack/plugins/transform/server/routes/index.ts b/x-pack/plugins/transform/server/routes/index.ts index 07c21e58e64e44..4f35b094017a41 100644 --- a/x-pack/plugins/transform/server/routes/index.ts +++ b/x-pack/plugins/transform/server/routes/index.ts @@ -6,6 +6,7 @@ import { RouteDependencies } from '../types'; +import { registerFieldHistogramsRoutes } from './api/field_histograms'; import { registerPrivilegesRoute } from './api/privileges'; import { registerTransformsRoutes } from './api/transforms'; @@ -15,6 +16,7 @@ export const addBasePath = (uri: string): string => `${API_BASE_PATH}${uri}`; export class ApiRoutes { setup(dependencies: RouteDependencies) { + registerFieldHistogramsRoutes(dependencies); registerPrivilegesRoute(dependencies); registerTransformsRoutes(dependencies); } diff --git a/x-pack/plugins/transform/server/shared_imports.ts b/x-pack/plugins/transform/server/shared_imports.ts new file mode 100644 index 00000000000000..d1f86ac375721b --- /dev/null +++ b/x-pack/plugins/transform/server/shared_imports.ts @@ -0,0 +1,7 @@ +/* + * 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 { getHistogramsForFields } from '../../ml/server'; diff --git a/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts b/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts new file mode 100644 index 00000000000000..8b21c367d29f65 --- /dev/null +++ b/x-pack/test/api_integration/apis/ml/data_visualizer/get_field_histograms.ts @@ -0,0 +1,122 @@ +/* + * 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'; +import { USER } from '../../../../functional/services/ml/security_common'; +import { COMMON_REQUEST_HEADERS } from '../../../../functional/services/ml/common'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const ml = getService('ml'); + + const fieldHistogramsTestData = { + testTitle: 'returns histogram data for fields', + index: 'ft_farequote', + user: USER.ML_POWERUSER, + requestBody: { + query: { bool: { should: [{ match_phrase: { airline: 'JZA' } }], minimum_should_match: 1 } }, + fields: [ + { fieldName: '@timestamp', type: 'date' }, + { fieldName: 'airline', type: 'string' }, + { fieldName: 'responsetime', type: 'number' }, + ], + samplerShardSize: -1, // No sampling, as otherwise counts could vary on each run. + }, + expected: { + responseCode: 200, + responseBody: [ + { + dataLength: 20, + type: 'numeric', + id: '@timestamp', + }, + { type: 'ordinal', dataLength: 1, id: 'airline' }, + { + dataLength: 20, + type: 'numeric', + id: 'responsetime', + }, + ], + }, + }; + + const errorTestData = { + testTitle: 'returns error for index which does not exist', + index: 'ft_farequote_not_exists', + user: USER.ML_POWERUSER, + requestBody: { + query: { bool: { must: [{ match_all: {} }] } }, + fields: [{ fieldName: 'responsetime', type: 'number' }], + samplerShardSize: -1, + }, + expected: { + responseCode: 404, + responseBody: { + statusCode: 404, + error: 'Not Found', + message: + '[index_not_found_exception] no such index [ft_farequote_not_exists], with { resource.type="index_or_alias" & resource.id="ft_farequote_not_exists" & index_uuid="_na_" & index="ft_farequote_not_exists" }', + }, + }, + }; + + async function runGetFieldHistogramsRequest( + index: string, + user: USER, + requestBody: object, + expectedResponsecode: number + ): Promise { + const { body } = await supertest + .post(`/api/ml/data_visualizer/get_field_histograms/${index}`) + .auth(user, ml.securityCommon.getPasswordForUser(user)) + .set(COMMON_REQUEST_HEADERS) + .send(requestBody) + .expect(expectedResponsecode); + + return body; + } + + describe('get_field_histograms', function () { + before(async () => { + await esArchiver.loadIfNeeded('ml/farequote'); + await ml.testResources.setKibanaTimeZoneToUTC(); + }); + + it(`${fieldHistogramsTestData.testTitle}`, async () => { + const body = await runGetFieldHistogramsRequest( + fieldHistogramsTestData.index, + fieldHistogramsTestData.user, + fieldHistogramsTestData.requestBody, + fieldHistogramsTestData.expected.responseCode + ); + + const expected = fieldHistogramsTestData.expected; + + const actual = body.map((b: any) => ({ + dataLength: b.data.length, + type: b.type, + id: b.id, + })); + expect(actual).to.eql(expected.responseBody); + }); + + it(`${errorTestData.testTitle}`, async () => { + const body = await runGetFieldHistogramsRequest( + errorTestData.index, + errorTestData.user, + errorTestData.requestBody, + errorTestData.expected.responseCode + ); + + expect(body.error).to.eql(errorTestData.expected.responseBody.error); + expect(body.message).to.eql(errorTestData.expected.responseBody.message); + }); + }); +}; diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 6cdb9caa1e2db7..4ae93296f9be0a 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -37,6 +37,18 @@ export default function ({ getService }: FtrProviderContext) { modelMemory: '5mb', createIndexPattern: true, expected: { + histogramCharts: [ + { chartAvailable: true, id: '1stFlrSF', legend: '334 - 4692' }, + { chartAvailable: true, id: 'BsmtFinSF1', legend: '0 - 5644' }, + { chartAvailable: true, id: 'BsmtQual', legend: '0 - 5' }, + { chartAvailable: true, id: 'CentralAir', legend: '2 categories' }, + { chartAvailable: true, id: 'Condition2', legend: '2 categories' }, + { chartAvailable: true, id: 'Electrical', legend: '2 categories' }, + { chartAvailable: true, id: 'ExterQual', legend: '1 - 4' }, + { chartAvailable: true, id: 'Exterior1st', legend: '2 categories' }, + { chartAvailable: true, id: 'Exterior2nd', legend: '3 categories' }, + { chartAvailable: true, id: 'Fireplaces', legend: '0 - 3' }, + ], row: { type: 'outlier_detection', status: 'stopped', @@ -84,6 +96,16 @@ export default function ({ getService }: FtrProviderContext) { await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewExists(); }); + it('enables the source data preview histogram charts', async () => { + await ml.dataFrameAnalyticsCreation.enableSourceDataPreviewHistogramCharts(); + }); + + it('displays the source data preview histogram charts', async () => { + await ml.dataFrameAnalyticsCreation.assertSourceDataPreviewHistogramCharts( + testData.expected.histogramCharts + ); + }); + it('displays the include fields selection', async () => { await ml.dataFrameAnalyticsCreation.assertIncludeFieldsSelectionExists(); }); diff --git a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts index 1b756bbaca5d89..fc4aaa4fbf5fdd 100644 --- a/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts +++ b/x-pack/test/functional/services/ml/data_frame_analytics_creation.ts @@ -128,6 +128,58 @@ export function MachineLearningDataFrameAnalyticsCreationProvider( await testSubjects.existOrFail('mlAnalyticsCreationDataGrid loaded', { timeout: 5000 }); }, + async assertIndexPreviewHistogramChartButtonExists() { + await testSubjects.existOrFail('mlAnalyticsCreationDataGridHistogramButton'); + }, + + async enableSourceDataPreviewHistogramCharts() { + await this.assertSourceDataPreviewHistogramChartButtonCheckState(false); + await testSubjects.click('mlAnalyticsCreationDataGridHistogramButton'); + await this.assertSourceDataPreviewHistogramChartButtonCheckState(true); + }, + + async assertSourceDataPreviewHistogramChartButtonCheckState(expectedCheckState: boolean) { + const actualCheckState = + (await testSubjects.getAttribute( + 'mlAnalyticsCreationDataGridHistogramButton', + 'aria-checked' + )) === 'true'; + expect(actualCheckState).to.eql( + expectedCheckState, + `Chart histogram button check state should be '${expectedCheckState}' (got '${actualCheckState}')` + ); + }, + + async assertSourceDataPreviewHistogramCharts( + expectedHistogramCharts: Array<{ chartAvailable: boolean; id: string; legend: string }> + ) { + // For each chart, get the content of each header cell and assert + // the legend text and column id and if the chart should be present or not. + await retry.tryForTime(5000, async () => { + for (const [index, expected] of expectedHistogramCharts.entries()) { + await testSubjects.existOrFail(`mlDataGridChart-${index}`); + + if (expected.chartAvailable) { + await testSubjects.existOrFail(`mlDataGridChart-${index}-histogram`); + } else { + await testSubjects.missingOrFail(`mlDataGridChart-${index}-histogram`); + } + + const actualLegend = await testSubjects.getVisibleText(`mlDataGridChart-${index}-legend`); + expect(actualLegend).to.eql( + expected.legend, + `Legend text for column '${index}' should be '${expected.legend}' (got '${actualLegend}')` + ); + + const actualId = await testSubjects.getVisibleText(`mlDataGridChart-${index}-id`); + expect(actualId).to.eql( + expected.id, + `Id text for column '${index}' should be '${expected.id}' (got '${actualId}')` + ); + } + }); + }, + async assertIncludeFieldsSelectionExists() { await testSubjects.existOrFail('mlAnalyticsCreateJobWizardIncludesSelect', { timeout: 5000 }); }, From fdc999769d9d9ab1b1e8856d71ca93a0ccc052fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Loix?= Date: Tue, 14 Jul 2020 13:47:03 +0200 Subject: [PATCH 25/57] [Index template wizard] Remove shadow and use border for components panels (#71606) --- .../component_template_selector/component_templates.scss | 4 +++- .../component_templates_selector.scss | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss index 51e8a829e81b16..026e63b2b4caab 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates.scss @@ -7,7 +7,8 @@ $heightHeader: $euiSizeL * 2; .componentTemplates { - @include euiBottomShadowFlat; + border: $euiBorderThin; + border-top: none; height: 100%; &__header { @@ -20,6 +21,7 @@ $heightHeader: $euiSizeL * 2; &__searchBox { border-bottom: $euiBorderThin; + border-top: $euiBorderThin; box-shadow: none; max-width: initial; } diff --git a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss index 61d5512da2cd9f..041fc1c8bf9a41 100644 --- a/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss +++ b/x-pack/plugins/index_management/public/application/components/component_templates/component_template_selector/component_templates_selector.scss @@ -6,7 +6,7 @@ height: 480px; &__selection { - @include euiBottomShadowFlat; + border: $euiBorderThin; padding: 0 $euiSize $euiSize; color: $euiColorDarkShade; From 97afee5b06dec9a8db28ec2309bd684199c21aad Mon Sep 17 00:00:00 2001 From: Robert Austin Date: Tue, 14 Jul 2020 08:12:51 -0400 Subject: [PATCH 26/57] [Security Solution] Hide timeline footer when Resolver is open (#71516) * Hide the Timeline footer, in the event viewer, if Resolver is showing --- .../events_viewer/events_viewer.tsx | 44 ++++++++++------- .../common/components/events_viewer/index.tsx | 10 +++- .../components/timeline/body/helpers.ts | 3 -- .../components/timeline/body/index.test.tsx | 30 +++++++++++- .../components/timeline/body/index.tsx | 5 +- .../components/timeline/header/index.tsx | 3 +- .../components/timeline/timeline.test.tsx | 28 +++++++++++ .../components/timeline/timeline.tsx | 48 +++++++++++-------- 8 files changed, 123 insertions(+), 48 deletions(-) diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx index 0a1f95d51e3009..a81c5facb07182 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/events_viewer.tsx @@ -67,6 +67,8 @@ interface Props { sort: Sort; toggleColumn: (column: ColumnHeaderOptions) => void; utilityBar?: (refetch: inputsModel.Refetch, totalCount: number) => React.ReactNode; + // If truthy, the graph viewer (Resolver) is showing + graphEventId: string | undefined; } const EventsViewerComponent: React.FC = ({ @@ -90,6 +92,7 @@ const EventsViewerComponent: React.FC = ({ sort, toggleColumn, utilityBar, + graphEventId, }) => { const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); @@ -191,22 +194,28 @@ const EventsViewerComponent: React.FC = ({ toggleColumn={toggleColumn} /> -