diff --git a/src/cli/cluster/run_kbn_optimizer.ts b/src/cli/cluster/run_kbn_optimizer.ts index b811fc1f6b294..e4bd6b98dd2b9 100644 --- a/src/cli/cluster/run_kbn_optimizer.ts +++ b/src/cli/cluster/run_kbn_optimizer.ts @@ -35,6 +35,7 @@ export function runKbnOptimizer(opts: Record, config: LegacyConfig) repoRoot: REPO_ROOT, watch: true, includeCoreBundle: true, + cache: !!opts.cache, oss: !!opts.oss, examples: !!opts.runExamples, pluginPaths: config.get('plugins.paths'), diff --git a/src/cli/serve/serve.js b/src/cli/serve/serve.js index 471939121143a..6b0daebd5a042 100644 --- a/src/cli/serve/serve.js +++ b/src/cli/serve/serve.js @@ -218,6 +218,7 @@ export default function(program) { "Don't put a proxy in front of the dev server, which adds a random basePath" ) .option('--no-watch', 'Prevents automatic restarts of the server in --dev mode') + .option('--no-cache', 'Disable the kbn/optimizer cache') .option('--no-dev-config', 'Prevents loading the kibana.dev.yml file in --dev mode'); } diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index aad7e6dcf270a..4317ede547202 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -87,6 +87,8 @@ export const AppContainer: FunctionComponent = ({ })) || null; } catch (e) { // TODO: add error UI + // eslint-disable-next-line no-console + console.error(e); } finally { setShowSpinner(false); setIsMounting(false); diff --git a/x-pack/plugins/apm/common/agent_name.ts b/x-pack/plugins/apm/common/agent_name.ts index 085828b729ea5..dac29a4f50682 100644 --- a/x-pack/plugins/apm/common/agent_name.ts +++ b/x-pack/plugins/apm/common/agent_name.ts @@ -41,3 +41,16 @@ export function isJavaAgentName( ): agentName is 'java' { return agentName === 'java'; } + +/** + * "Normalizes" and agent name by: + * + * * Converting to lowercase + * * Converting "rum-js" to "js-base" + * + * This helps dealing with some older agent versions + */ +export function getNormalizedAgentName(agentName?: string) { + const lowercased = agentName && agentName.toLowerCase(); + return isRumAgentName(lowercased) ? 'js-base' : lowercased; +} diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx index 340c299f52c0b..2d1e99096a44f 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/Cytoscape.stories.tsx @@ -186,6 +186,13 @@ storiesOf('app/ServiceMap/Cytoscape', module) 'agent.name': 'dotnet' } }, + { + data: { + id: 'dotNet', + 'service.name': 'dotNet service', + 'agent.name': 'dotNet' + } + }, { data: { id: 'go', diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts index 9fe5cbd23b07c..1b4bf1b77791c 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/icons.ts @@ -5,7 +5,7 @@ */ import cytoscape from 'cytoscape'; -import { isRumAgentName } from '../../../../common/agent_name'; +import { getNormalizedAgentName } from '../../../../common/agent_name'; import { AGENT_NAME, SPAN_SUBTYPE, @@ -87,8 +87,7 @@ const agentIcons: { [key: string]: string } = { }; function getAgentIcon(agentName?: string) { - // RUM can have multiple names. Normalize it - const normalizedAgentName = isRumAgentName(agentName) ? 'js-base' : agentName; + const normalizedAgentName = getNormalizedAgentName(agentName); return normalizedAgentName && agentIcons[normalizedAgentName]; } diff --git a/x-pack/plugins/endpoint/common/models/event.ts b/x-pack/plugins/endpoint/common/models/event.ts index 47f39d2d11797..192daba4a717d 100644 --- a/x-pack/plugins/endpoint/common/models/event.ts +++ b/x-pack/plugins/endpoint/common/models/event.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 { LegacyEndpointEvent, ResolverEvent } from '../types'; export function isLegacyEvent(event: ResolverEvent): event is LegacyEndpointEvent { @@ -46,3 +45,23 @@ export function parentEntityId(event: ResolverEvent): string | undefined { } return event.process.parent?.entity_id; } + +export function eventType(event: ResolverEvent): string { + // Returning "Process" as a catch-all here because it seems pretty general + let eventCategoryToReturn: string = 'Process'; + if (isLegacyEvent(event)) { + const legacyFullType = event.endgame.event_type_full; + if (legacyFullType) { + return legacyFullType; + } + } else { + const eventCategories = event.event.category; + const eventCategory = + typeof eventCategories === 'string' ? eventCategories : eventCategories[0] || ''; + + if (eventCategory) { + eventCategoryToReturn = eventCategory; + } + } + return eventCategoryToReturn; +} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts index a26f43e1f8cc0..462f6e251d5d0 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/actions.ts @@ -44,6 +44,15 @@ interface AppRequestedResolverData { readonly type: 'appRequestedResolverData'; } +/** + * The action dispatched when the app requests related event data for one or more + * subjects (whose ids should be included as an array @ `payload`) + */ +interface UserRequestedRelatedEventData { + readonly type: 'userRequestedRelatedEventData'; + readonly payload: ResolverEvent; +} + /** * When the user switches the "active descendant" of the Resolver. * The "active descendant" (from the point of view of the parent element) @@ -77,6 +86,28 @@ interface UserSelectedResolverNode { }; } +/** + * This action should dispatch to indicate that the user chose to + * focus on examining the related events of a particular ResolverEvent. + * Optionally, this can be bound by a category of related events (e.g. 'file' or 'dns') + */ +interface UserSelectedRelatedEventCategory { + readonly type: 'userSelectedRelatedEventCategory'; + readonly payload: { + subject: ResolverEvent; + category?: string; + }; +} + +/** + * This action should dispatch to indicate that the user chose to focus + * on examining alerts related to a particular ResolverEvent + */ +interface UserSelectedRelatedAlerts { + readonly type: 'userSelectedRelatedAlerts'; + readonly payload: ResolverEvent; +} + export type ResolverAction = | CameraAction | DataAction @@ -84,4 +115,7 @@ export type ResolverAction = | UserChangedSelectedEvent | AppRequestedResolverData | UserFocusedOnResolverNode - | UserSelectedResolverNode; + | UserSelectedResolverNode + | UserRequestedRelatedEventData + | UserSelectedRelatedEventCategory + | UserSelectedRelatedAlerts; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts index 3ec15f2f1985d..8c84d8f82b874 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/action.ts @@ -5,6 +5,7 @@ */ import { ResolverEvent } from '../../../../../common/types'; +import { RelatedEventDataEntry } from '../../types'; interface ServerReturnedResolverData { readonly type: 'serverReturnedResolverData'; @@ -15,4 +16,24 @@ interface ServerFailedToReturnResolverData { readonly type: 'serverFailedToReturnResolverData'; } -export type DataAction = ServerReturnedResolverData | ServerFailedToReturnResolverData; +/** + * Will occur when a request for related event data is fulfilled by the API. + */ +interface ServerReturnedRelatedEventData { + readonly type: 'serverReturnedRelatedEventData'; + readonly payload: Map; +} + +/** + * Will occur when a request for related event data is unsuccessful. + */ +interface ServerFailedToReturnRelatedEventData { + readonly type: 'serverFailedToReturnRelatedEventData'; + readonly payload: ResolverEvent; +} + +export type DataAction = + | ServerReturnedResolverData + | ServerFailedToReturnResolverData + | ServerReturnedRelatedEventData + | ServerFailedToReturnRelatedEventData; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts index fc307002819a9..9dd6bcdf385ae 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/reducer.ts @@ -12,6 +12,7 @@ function initialState(): DataState { results: [], isLoading: false, hasError: false, + resultsEnrichedWithRelatedEventInfo: new Map(), }; } @@ -23,6 +24,26 @@ export const dataReducer: Reducer = (state = initialS isLoading: false, hasError: false, }; + } else if (action.type === 'userRequestedRelatedEventData') { + const resolverEvent = action.payload; + const currentStatsMap = new Map(state.resultsEnrichedWithRelatedEventInfo); + /** + * Set the waiting indicator for this event to indicate that related event results are pending. + * It will be replaced by the actual results from the API when they are returned. + */ + currentStatsMap.set(resolverEvent, 'waitingForRelatedEventData'); + return { ...state, resultsEnrichedWithRelatedEventInfo: currentStatsMap }; + } else if (action.type === 'serverFailedToReturnRelatedEventData') { + const currentStatsMap = new Map(state.resultsEnrichedWithRelatedEventInfo); + const resolverEvent = action.payload; + currentStatsMap.set(resolverEvent, 'error'); + return { ...state, resultsEnrichedWithRelatedEventInfo: currentStatsMap }; + } else if (action.type === 'serverReturnedRelatedEventData') { + const relatedDataEntries = new Map([ + ...state.resultsEnrichedWithRelatedEventInfo, + ...action.payload, + ]); + return { ...state, resultsEnrichedWithRelatedEventInfo: relatedDataEntries }; } else if (action.type === 'appRequestedResolverData') { return { ...state, diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.test.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.test.ts new file mode 100644 index 0000000000000..561b0da12bcb1 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Store, createStore } from 'redux'; +import { DataAction } from './action'; +import { dataReducer } from './reducer'; +import { + DataState, + RelatedEventDataEntry, + RelatedEventDataEntryWithStats, + RelatedEventData, +} from '../../types'; +import { ResolverEvent } from '../../../../../common/types'; +import { relatedEventStats, relatedEvents } from './selectors'; + +describe('resolver data selectors', () => { + const store: Store = createStore(dataReducer, undefined); + describe('when related event data is reduced into state with no results', () => { + let relatedEventInfoBeforeAction: RelatedEventData; + beforeEach(() => { + relatedEventInfoBeforeAction = new Map(relatedEvents(store.getState()) || []); + const payload: Map = new Map(); + const action: DataAction = { type: 'serverReturnedRelatedEventData', payload }; + store.dispatch(action); + }); + it('should have the same related info as before the action', () => { + const relatedInfoAfterAction = relatedEvents(store.getState()); + expect(relatedInfoAfterAction).toEqual(relatedEventInfoBeforeAction); + }); + }); + describe('when related event data is reduced into state with 2 dns results', () => { + let mockBaseEvent: ResolverEvent; + beforeEach(() => { + mockBaseEvent = {} as ResolverEvent; + function dnsRelatedEventEntry() { + const fakeEvent = {} as ResolverEvent; + return { relatedEvent: fakeEvent, relatedEventType: 'dns' }; + } + const payload: Map = new Map([ + [ + mockBaseEvent, + { + relatedEvents: [dnsRelatedEventEntry(), dnsRelatedEventEntry()], + }, + ], + ]); + const action: DataAction = { type: 'serverReturnedRelatedEventData', payload }; + store.dispatch(action); + }); + it('should compile stats reflecting a count of 2 for dns', () => { + const actualStats = relatedEventStats(store.getState()); + const statsForFakeEvent = actualStats.get(mockBaseEvent)! as RelatedEventDataEntryWithStats; + expect(statsForFakeEvent.stats).toEqual({ dns: 2 }); + }); + }); +}); diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts index 59ee4b3b87505..413f4db1cc99e 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/data/selectors.ts @@ -14,6 +14,8 @@ import { ProcessWithWidthMetadata, Matrix3, AdjacentProcessMap, + RelatedEventData, + RelatedEventDataEntryWithStats, } from '../../types'; import { ResolverEvent } from '../../../../../common/types'; import { Vector2 } from '../../types'; @@ -405,6 +407,86 @@ export const indexedProcessTree = createSelector(graphableProcesses, function in return indexedProcessTreeFactory(graphableProcesses); }); +/** + * Process events that will be graphed. + */ +export const relatedEventResults = function(data: DataState) { + return data.resultsEnrichedWithRelatedEventInfo; +}; + +/** + * This selector compiles the related event data attached in `relatedEventResults` + * into a `RelatedEventData` map of ResolverEvents to statistics about their related events + */ +export const relatedEventStats = createSelector(relatedEventResults, function getRelatedEvents( + /* eslint-disable no-shadow */ + relatedEventResults + /* eslint-enable no-shadow */ +) { + /* eslint-disable no-shadow */ + const relatedEventStats: RelatedEventData = new Map(); + /* eslint-enable no-shadow */ + if (!relatedEventResults) { + return relatedEventStats; + } + + for (const updatedEvent of relatedEventResults.keys()) { + const newStatsEntry = relatedEventResults.get(updatedEvent); + if (newStatsEntry === 'error') { + // If the entry is an error, return it as is + relatedEventStats.set(updatedEvent, newStatsEntry); + continue; + } + if (typeof newStatsEntry === 'object') { + /** + * Otherwise, it should be a valid stats entry. + * Do the work to compile the stats. + * Folowing reduction, this will be a record like + * {DNS: 10, File: 2} etc. + */ + const statsForEntry = newStatsEntry?.relatedEvents.reduce( + (compiledStats: Record, relatedEvent: { relatedEventType: string }) => { + compiledStats[relatedEvent.relatedEventType] = + (compiledStats[relatedEvent.relatedEventType] || 0) + 1; + return compiledStats; + }, + {} + ); + + const newRelatedEventStats: RelatedEventDataEntryWithStats = Object.assign(newStatsEntry, { + stats: statsForEntry, + }); + relatedEventStats.set(updatedEvent, newRelatedEventStats); + } + } + return relatedEventStats; +}); + +/** + * This selects `RelatedEventData` maps specifically for graphable processes + */ +export const relatedEvents = createSelector( + graphableProcesses, + relatedEventStats, + function getRelatedEvents( + /* eslint-disable no-shadow */ + graphableProcesses, + relatedEventStats + /* eslint-enable no-shadow */ + ) { + const eventsRelatedByProcess: RelatedEventData = new Map(); + /* eslint-disable no-shadow */ + return graphableProcesses.reduce((relatedEvents, graphableProcess) => { + /* eslint-enable no-shadow */ + const relatedEventDataEntry = relatedEventStats?.get(graphableProcess); + if (relatedEventDataEntry) { + relatedEvents.set(graphableProcess, relatedEventDataEntry); + } + return relatedEvents; + }, eventsRelatedByProcess); + } +); + export const processAdjacencies = createSelector( indexedProcessTree, graphableProcesses, diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts index c7177c6387e7a..06758022b05c5 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/middleware.ts @@ -5,9 +5,10 @@ */ import { Dispatch, MiddlewareAPI } from 'redux'; +import { HttpHandler } from 'kibana/public'; import { KibanaReactContextValue } from '../../../../../../../src/plugins/kibana_react/public'; import { EndpointPluginServices } from '../../../plugin'; -import { ResolverState, ResolverAction } from '../types'; +import { ResolverState, ResolverAction, RelatedEventDataEntry } from '../types'; import { ResolverEvent, ResolverNode } from '../../../../common/types'; import * as event from '../../../../common/models/event'; @@ -30,6 +31,33 @@ function flattenEvents(children: ResolverNode[], events: ResolverEvent[] = []): }, events); } +type RelatedEventAPIResponse = 'error' | { events: ResolverEvent[] }; +/** + * As the design goal of this stopgap was to prevent saturating the server with /events + * requests, this generator intentionally processes events in serial rather than in parallel. + * @param eventsToFetch + * events to run against the /id/events API + * @param httpGetter + * the HttpHandler to use + */ +async function* getEachRelatedEventsResult( + eventsToFetch: ResolverEvent[], + httpGetter: HttpHandler +): AsyncGenerator<[ResolverEvent, RelatedEventAPIResponse]> { + for (const eventToQueryForRelateds of eventsToFetch) { + const id = event.entityId(eventToQueryForRelateds); + let result: RelatedEventAPIResponse; + try { + result = await httpGetter(`/api/endpoint/resolver/${id}/events`, { + query: { events: 100 }, + }); + } catch (e) { + result = 'error'; + } + yield [eventToQueryForRelateds, result]; + } +} + export const resolverMiddlewareFactory: MiddlewareFactory = context => { return api => next => async (action: ResolverAction) => { next(action); @@ -78,5 +106,44 @@ export const resolverMiddlewareFactory: MiddlewareFactory = context => { } } } + + if (action.type === 'userRequestedRelatedEventData') { + if (typeof context !== 'undefined') { + const response: Map = new Map(); + for await (const results of getEachRelatedEventsResult( + [action.payload], + context.services.http.get + )) { + /** + * results here will take the shape of + * [event requested , response of event against the /related api] + */ + const [baseEvent, apiResults] = results; + if (apiResults === 'error') { + api.dispatch({ + type: 'serverFailedToReturnRelatedEventData', + payload: results[0], + }); + continue; + } + + const fetchedResults = apiResults.events; + // pack up the results into response + const relatedEventEntry = fetchedResults.map(relatedEvent => { + return { + relatedEvent, + relatedEventType: event.eventType(relatedEvent), + }; + }); + + response.set(baseEvent, { relatedEvents: relatedEventEntry }); + } + + api.dispatch({ + type: 'serverReturnedRelatedEventData', + payload: response, + }); + } + } }; }; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts index 7d09d90881da9..493c23621a6cf 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/store/selectors.ts @@ -60,6 +60,11 @@ export const processAdjacencies = composeSelectors( dataSelectors.processAdjacencies ); +/** + * Returns a map of `ResolverEvent`s to their related `ResolverEvent`s + */ +export const relatedEvents = composeSelectors(dataStateSelector, dataSelectors.relatedEvents); + /** * Returns the id of the "current" tree node (fake-focused) */ diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts index 17aa598720c59..32fefba8f0f20 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/types.ts @@ -9,6 +9,7 @@ import { Store } from 'redux'; import { ResolverAction } from './store/actions'; export { ResolverAction } from './store/actions'; import { ResolverEvent } from '../../../common/types'; +import { eventType } from '../../../common/models/event'; /** * Redux state for the Resolver feature. Properties on this interface are populated via multiple reducers using redux's `combineReducers`. @@ -130,6 +131,52 @@ export type CameraState = { } ); +/** + * This represents all the raw data (sans statistics, metadata, etc.) + * about a particular subject's related events + */ +export interface RelatedEventDataEntry { + relatedEvents: Array<{ + relatedEvent: ResolverEvent; + relatedEventType: ReturnType; + }>; +} + +/** + * Represents the status of the request for related event data, which will be either the data, + * a value indicating that it's still waiting for the data or an Error indicating the data can't be retrieved as expected + */ +export type RelatedEventDataResults = + | RelatedEventDataEntry + | 'waitingForRelatedEventData' + | 'error'; + +/** + * This represents the raw related events data enhanced with statistics + * (e.g. counts of items grouped by their related event types) + */ +export type RelatedEventDataEntryWithStats = RelatedEventDataEntry & { + stats: Record; +}; + +/** + * The status or value of any particular event's related events w.r.t. their valence to the current view. + * One of: + * `RelatedEventDataEntryWithStats` when results have been received and processed and are ready to display + * `waitingForRelatedEventData` when related events have been requested but have not yet matriculated + * `Error` when the request for any event encounters an error during service + */ +export type RelatedEventEntryWithStatsOrWaiting = + | RelatedEventDataEntryWithStats + | `waitingForRelatedEventData` + | 'error'; + +/** + * This represents a Map that will return either a `RelatedEventDataEntryWithStats` + * or a `waitingForRelatedEventData` symbol when referenced with a unique event. + */ +export type RelatedEventData = Map; + /** * State for `data` reducer which handles receiving Resolver data from the backend. */ @@ -137,6 +184,7 @@ export interface DataState { readonly results: readonly ResolverEvent[]; isLoading: boolean; hasError: boolean; + resultsEnrichedWithRelatedEventInfo: Map; } export type Vector2 = readonly [number, number]; diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx index 2e7ca65c92dc1..5275ba3ec5b4c 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/index.tsx @@ -57,7 +57,7 @@ export const Resolver = styled( const dispatch: (action: ResolverAction) => unknown = useDispatch(); const { processToAdjacencyMap } = useSelector(selectors.processAdjacencies); - + const relatedEvents = useSelector(selectors.relatedEvents); const { projectionMatrix, ref, onMouseDown } = useCamera(); const isLoading = useSelector(selectors.isLoading); const hasError = useSelector(selectors.hasError); @@ -116,6 +116,7 @@ export const Resolver = styled( projectionMatrix={projectionMatrix} event={processEvent} adjacentNodeMap={adjacentNodeMap} + relatedEvents={relatedEvents.get(processEvent)} /> ); })} diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx index 27844f09e2272..32928d511a1f9 100644 --- a/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/process_event_dot.tsx @@ -9,14 +9,21 @@ import styled from 'styled-components'; import { i18n } from '@kbn/i18n'; import { htmlIdGenerator, + EuiI18nNumber, EuiKeyboardAccessible, - EuiButton, EuiFlexGroup, EuiFlexItem, } from '@elastic/eui'; import { useSelector } from 'react-redux'; +import { NodeSubMenu, subMenuAssets } from './submenu'; import { applyMatrix3 } from '../lib/vector2'; -import { Vector2, Matrix3, AdjacentProcessMap, ResolverProcessType } from '../types'; +import { + Vector2, + Matrix3, + AdjacentProcessMap, + ResolverProcessType, + RelatedEventEntryWithStatsOrWaiting, +} from '../types'; import { SymbolIds, NamedColors } from './defs'; import { ResolverEvent } from '../../../../common/types'; import { useResolverDispatch } from './use_resolver_dispatch'; @@ -59,46 +66,129 @@ const nodeAssets = { }, }; -const ChildEventsButton = React.memo(() => { - return ( - ) => { - clickEvent.preventDefault(); - clickEvent.stopPropagation(); - }, [])} - color="ghost" - size="s" - iconType="arrowDown" - iconSide="right" - tabIndex={-1} - > - {i18n.translate('xpack.endpoint.resolver.relatedEvents', { - defaultMessage: 'Events', - })} - - ); -}); - -const RelatedAlertsButton = React.memo(() => { +/** + * Take a gross `schemaName` and return a beautiful translated one. + */ +const getDisplayName: (schemaName: string) => string = function nameInSchemaToDisplayName( + schemaName: string +) { + const displayNameRecord: Record = { + application: i18n.translate('xpack.endpoint.resolver.applicationEventTypeDisplayName', { + defaultMessage: 'Application', + }), + apm: i18n.translate('xpack.endpoint.resolver.apmEventTypeDisplayName', { + defaultMessage: 'APM', + }), + audit: i18n.translate('xpack.endpoint.resolver.auditEventTypeDisplayName', { + defaultMessage: 'Audit', + }), + authentication: i18n.translate('xpack.endpoint.resolver.authenticationEventTypeDisplayName', { + defaultMessage: 'Authentication', + }), + certificate: i18n.translate('xpack.endpoint.resolver.certificateEventTypeDisplayName', { + defaultMessage: 'Certificate', + }), + cloud: i18n.translate('xpack.endpoint.resolver.cloudEventTypeDisplayName', { + defaultMessage: 'Cloud', + }), + database: i18n.translate('xpack.endpoint.resolver.databaseEventTypeDisplayName', { + defaultMessage: 'Database', + }), + driver: i18n.translate('xpack.endpoint.resolver.driverEventTypeDisplayName', { + defaultMessage: 'Driver', + }), + email: i18n.translate('xpack.endpoint.resolver.emailEventTypeDisplayName', { + defaultMessage: 'Email', + }), + file: i18n.translate('xpack.endpoint.resolver.fileEventTypeDisplayName', { + defaultMessage: 'File', + }), + host: i18n.translate('xpack.endpoint.resolver.hostEventTypeDisplayName', { + defaultMessage: 'Host', + }), + iam: i18n.translate('xpack.endpoint.resolver.iamEventTypeDisplayName', { + defaultMessage: 'IAM', + }), + iam_group: i18n.translate('xpack.endpoint.resolver.iam_groupEventTypeDisplayName', { + defaultMessage: 'IAM Group', + }), + intrusion_detection: i18n.translate( + 'xpack.endpoint.resolver.intrusion_detectionEventTypeDisplayName', + { + defaultMessage: 'Intrusion Detection', + } + ), + malware: i18n.translate('xpack.endpoint.resolver.malwareEventTypeDisplayName', { + defaultMessage: 'Malware', + }), + network_flow: i18n.translate('xpack.endpoint.resolver.network_flowEventTypeDisplayName', { + defaultMessage: 'Network Flow', + }), + network: i18n.translate('xpack.endpoint.resolver.networkEventTypeDisplayName', { + defaultMessage: 'Network', + }), + package: i18n.translate('xpack.endpoint.resolver.packageEventTypeDisplayName', { + defaultMessage: 'Package', + }), + process: i18n.translate('xpack.endpoint.resolver.processEventTypeDisplayName', { + defaultMessage: 'Process', + }), + registry: i18n.translate('xpack.endpoint.resolver.registryEventTypeDisplayName', { + defaultMessage: 'Registry', + }), + session: i18n.translate('xpack.endpoint.resolver.sessionEventTypeDisplayName', { + defaultMessage: 'Session', + }), + service: i18n.translate('xpack.endpoint.resolver.serviceEventTypeDisplayName', { + defaultMessage: 'Service', + }), + socket: i18n.translate('xpack.endpoint.resolver.socketEventTypeDisplayName', { + defaultMessage: 'Socket', + }), + vulnerability: i18n.translate('xpack.endpoint.resolver.vulnerabilityEventTypeDisplayName', { + defaultMessage: 'Vulnerability', + }), + web: i18n.translate('xpack.endpoint.resolver.webEventTypeDisplayName', { + defaultMessage: 'Web', + }), + alert: i18n.translate('xpack.endpoint.resolver.alertEventTypeDisplayName', { + defaultMessage: 'Alert', + }), + security: i18n.translate('xpack.endpoint.resolver.securityEventTypeDisplayName', { + defaultMessage: 'Security', + }), + dns: i18n.translate('xpack.endpoint.resolver.dnsEventTypeDisplayName', { + defaultMessage: 'DNS', + }), + clr: i18n.translate('xpack.endpoint.resolver.clrEventTypeDisplayName', { + defaultMessage: 'CLR', + }), + image_load: i18n.translate('xpack.endpoint.resolver.image_loadEventTypeDisplayName', { + defaultMessage: 'Image Load', + }), + powershell: i18n.translate('xpack.endpoint.resolver.powershellEventTypeDisplayName', { + defaultMessage: 'Powershell', + }), + wmi: i18n.translate('xpack.endpoint.resolver.wmiEventTypeDisplayName', { + defaultMessage: 'WMI', + }), + api: i18n.translate('xpack.endpoint.resolver.apiEventTypeDisplayName', { + defaultMessage: 'API', + }), + user: i18n.translate('xpack.endpoint.resolver.userEventTypeDisplayName', { + defaultMessage: 'User', + }), + }; return ( - ) => { - clickEvent.preventDefault(); - clickEvent.stopPropagation(); - }, [])} - color="ghost" - size="s" - tabIndex={-1} - > - {i18n.translate('xpack.endpoint.resolver.relatedAlerts', { - defaultMessage: 'Related Alerts', - })} - + displayNameRecord[schemaName] || + i18n.translate('xpack.endpoint.resolver.userEventTypeDisplayUnknown', { + defaultMessage: 'Unknown', + }) ); -}); +}; /** - * An artefact that represents a process node. + * An artifact that represents a process node and the things associated with it in the Resolver */ export const ProcessEventDot = styled( React.memo( @@ -108,6 +198,7 @@ export const ProcessEventDot = styled( event, projectionMatrix, adjacentNodeMap, + relatedEvents, }: { /** * A `className` string provided by `styled` @@ -129,6 +220,11 @@ export const ProcessEventDot = styled( * map of what nodes are "adjacent" to this one in "up, down, previous, next" directions */ adjacentNodeMap: AdjacentProcessMap; + /** + * A collection of events related to the current node and statistics (e.g. counts indexed by event type) + * to provide the user some visibility regarding the contents thereof. + */ + relatedEvents?: RelatedEventEntryWithStatsOrWaiting; }) => { /** * Convert the position, which is in 'world' coordinates, to screen coordinates. @@ -203,7 +299,6 @@ export const ProcessEventDot = styled( ]); const labelId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]); const descriptionId = useMemo(() => resolverNodeIdGenerator(), [resolverNodeIdGenerator]); - const isActiveDescendant = nodeId === activeDescendantId; const isSelectedDescendant = nodeId === selectedDescendantId; @@ -230,6 +325,70 @@ export const ProcessEventDot = styled( }); }, [animationTarget, dispatch, nodeId]); + const handleRelatedEventRequest = useCallback(() => { + dispatch({ + type: 'userRequestedRelatedEventData', + payload: event, + }); + }, [dispatch, event]); + + const handleRelatedAlertsRequest = useCallback(() => { + dispatch({ + type: 'userSelectedRelatedAlerts', + payload: event, + }); + }, [dispatch, event]); + /** + * Enumerates the stats for related events to display with the node as options, + * generally in the form `number of related events in category` `category title` + * e.g. "10 DNS", "230 File" + */ + const relatedEventOptions = useMemo(() => { + if (relatedEvents === 'error') { + // Return an empty set of options if there was an error requesting them + return []; + } + const relatedStats = typeof relatedEvents === 'object' && relatedEvents.stats; + if (!relatedStats) { + // Return an empty set of options if there are no stats to report + return []; + } + // If we have entries to show, map them into options to display in the selectable list + return Object.entries(relatedStats).map(statsEntry => { + const displayName = getDisplayName(statsEntry[0]); + return { + prefix: , + optionTitle: `${displayName}`, + action: () => { + dispatch({ + type: 'userSelectedRelatedEventCategory', + payload: { + subject: event, + category: statsEntry[0], + }, + }); + }, + }; + }); + }, [relatedEvents, dispatch, event]); + + const relatedEventStatusOrOptions = (() => { + if (!relatedEvents) { + // If related events have not yet been requested + return subMenuAssets.initialMenuStatus; + } + if (relatedEvents === 'error') { + // If there was an error when we tried to request the events + return subMenuAssets.menuError; + } + if (relatedEvents === 'waitingForRelatedEventData') { + // If we're waiting for events to be returned + // Pass on the waiting symbol + return relatedEvents; + } + return relatedEventOptions; + })(); + /* eslint-disable jsx-a11y/click-events-have-key-events */ /** * Key event handling (e.g. 'Enter'/'Space') is provisioned by the `EuiKeyboardAccessible` component @@ -359,11 +518,18 @@ export const ProcessEventDot = styled( {magFactorX >= 2 && ( - - + + - + )} @@ -383,9 +549,10 @@ export const ProcessEventDot = styled( border-radius: 10%; white-space: nowrap; will-change: left, top, width, height; - contain: strict; + contain: layout; min-width: 280px; min-height: 90px; + overflow-y: visible; //dasharray & dashoffset should be equal to "pull" the stroke back //when it is transitioned. @@ -400,10 +567,27 @@ export const ProcessEventDot = styled( transition-duration: 1s; stroke-dashoffset: 0; } + + & .related-dropdown { + width: 4.5em; + } + & .euiSelectableList-bordered { + border-top-right-radius: 0px; + border-top-left-radius: 0px; + } + & .euiSelectableListItem { + background-color: black; + } + & .euiSelectableListItem path { + fill: white; + } + & .euiSelectableListItem__text { + color: white; + } `; const processTypeToCube: Record = { - processCreated: 'terminatedProcessCube', + processCreated: 'runningProcessCube', processRan: 'runningProcessCube', processTerminated: 'terminatedProcessCube', unknownProcessEvent: 'runningProcessCube', diff --git a/x-pack/plugins/endpoint/public/embeddables/resolver/view/submenu.tsx b/x-pack/plugins/endpoint/public/embeddables/resolver/view/submenu.tsx new file mode 100644 index 0000000000000..9f6427d801ce4 --- /dev/null +++ b/x-pack/plugins/endpoint/public/embeddables/resolver/view/submenu.tsx @@ -0,0 +1,178 @@ +/* + * 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'; +import React, { ReactNode, useState, useMemo, useCallback } from 'react'; +import { EuiSelectable, EuiButton } from '@elastic/eui'; +import styled from 'styled-components'; + +/** + * i18n-translated titles for submenus and identifiers for display of states: + * initialMenuStatus: submenu before it has been opened / requested data + * menuError: if the submenu requested data, but received an error + */ +export const subMenuAssets = { + initialMenuStatus: i18n.translate('xpack.endpoint.resolver.relatedNotRetrieved', { + defaultMessage: 'Related Events have not yet been retrieved.', + }), + menuError: i18n.translate('xpack.endpoint.resolver.relatedRetrievalError', { + defaultMessage: 'There was an error retrieving related events.', + }), + relatedAlerts: { + title: i18n.translate('xpack.endpoint.resolver.relatedAlerts', { + defaultMessage: 'Related Alerts', + }), + }, + relatedEvents: { + title: i18n.translate('xpack.endpoint.resolver.relatedEvents', { + defaultMessage: 'Events', + }), + }, +}; + +interface ResolverSubmenuOption { + optionTitle: string; + action: () => unknown; + prefix?: number | JSX.Element; +} + +export type ResolverSubmenuOptionList = ResolverSubmenuOption[] | string; + +const OptionList = React.memo( + ({ + subMenuOptions, + isLoading, + }: { + subMenuOptions: ResolverSubmenuOptionList; + isLoading: boolean; + }) => { + const [options, setOptions] = useState(() => + typeof subMenuOptions !== 'object' + ? [] + : subMenuOptions.map((opt: ResolverSubmenuOption): { + label: string; + prepend?: ReactNode; + } => { + return opt.prefix + ? { + label: opt.optionTitle, + prepend: {opt.prefix} , + } + : { + label: opt.optionTitle, + prepend: , + }; + }) + ); + return useMemo( + () => ( + { + setOptions(newOptions); + }} + listProps={{ showIcons: true, bordered: true }} + isLoading={isLoading} + > + {list => list} + + ), + [isLoading, options] + ); + } +); + +/** + * A Submenu to be displayed in one of two forms: + * 1) Provided a collection of `optionsWithActions`: it will call `menuAction` then - if and when menuData becomes available - display each item with an optional prefix and call the supplied action for the options when that option is clicked. + * 2) Provided `optionsWithActions` is undefined, it will call the supplied `menuAction` when its host button is clicked. + */ +export const NodeSubMenu = styled( + React.memo( + ({ + menuTitle, + menuAction, + optionsWithActions, + className, + }: { menuTitle: string; className?: string; menuAction: () => unknown } & { + optionsWithActions?: ResolverSubmenuOptionList | string | undefined; + }) => { + const [menuIsOpen, setMenuOpen] = useState(false); + const handleMenuOpenClick = useCallback( + (clickEvent: React.MouseEvent) => { + // stopping propagation/default to prevent other node animations from triggering + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + setMenuOpen(!menuIsOpen); + }, + [menuIsOpen] + ); + const handleMenuActionClick = useCallback( + (clickEvent: React.MouseEvent) => { + // stopping propagation/default to prevent other node animations from triggering + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + if (typeof menuAction === 'function') menuAction(); + setMenuOpen(true); + }, + [menuAction] + ); + + const isMenuLoading = optionsWithActions === 'waitingForRelatedEventData'; + + if (!optionsWithActions) { + /** + * When called with a `menuAction` + * Render without dropdown and call the supplied action when host button is clicked + */ + return ( +
+ + {menuTitle} + +
+ ); + } + /** + * When called with a set of `optionsWithActions`: + * Render with a panel of options that appear when the menu host button is clicked + */ + return ( +
+ + {menuTitle} + + {menuIsOpen && typeof optionsWithActions === 'object' && ( + + )} +
+ ); + } + ) +)` + margin: 0; + padding: 0; + border: none; + display: flex; + flex-flow: column; + &.is-open .euiButton { + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; + } + &.is-open .euiSelectableListItem__prepend { + color: white; + } +`; diff --git a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts index 12781f2f77d17..8bc80c69ce9b2 100644 --- a/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts +++ b/x-pack/plugins/ingest_manager/server/routes/setup/handlers.ts @@ -74,12 +74,22 @@ export const createFleetSetupHandler: RequestHandler< export const ingestManagerSetupHandler: RequestHandler = async (context, request, response) => { const soClient = context.core.savedObjects.client; const callCluster = context.core.elasticsearch.adminClient.callAsCurrentUser; + const logger = appContextService.getLogger(); try { await setupIngestManager(soClient, callCluster); return response.ok({ body: { isInitialized: true }, }); } catch (e) { + if (e.isBoom) { + logger.error(e.output.payload.message); + return response.customError({ + statusCode: e.output.statusCode, + body: { message: e.output.payload.message }, + }); + } + logger.error(e.message); + logger.error(e.stack); return response.customError({ statusCode: 500, body: { message: e.message }, diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts index 6ef6f863753b5..2c452f16cc104 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/install.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import Boom from 'boom'; import { Dataset, RegistryPackage, ElasticsearchAssetType, TemplateRef } from '../../../../types'; import { CallESAsCurrentUser } from '../../../../types'; import { Field, loadFieldsFromYaml, processFields } from '../../fields/field'; @@ -20,8 +21,8 @@ export const installTemplates = async ( // install any pre-built index template assets, // atm, this is only the base package's global index templates // Install component templates first, as they are used by the index templates - installPreBuiltComponentTemplates(pkgName, pkgVersion, callCluster); - installPreBuiltTemplates(pkgName, pkgVersion, callCluster); + await installPreBuiltComponentTemplates(pkgName, pkgVersion, callCluster); + await installPreBuiltTemplates(pkgName, pkgVersion, callCluster); // build templates per dataset from yml files const datasets = registryPackage.datasets; @@ -53,16 +54,7 @@ const installPreBuiltTemplates = async ( pkgVersion, (entry: Registry.ArchiveEntry) => isTemplate(entry) ); - // templatePaths.forEach(async path => { - // const { file } = Registry.pathParts(path); - // const templateName = file.substr(0, file.lastIndexOf('.')); - // const content = JSON.parse(Registry.getAsset(path).toString('utf8')); - // await callCluster('indices.putTemplate', { - // name: templateName, - // body: content, - // }); - // }); - templatePaths.forEach(async path => { + const templateInstallPromises = templatePaths.map(async path => { const { file } = Registry.pathParts(path); const templateName = file.substr(0, file.lastIndexOf('.')); const content = JSON.parse(Registry.getAsset(path).toString('utf8')); @@ -91,8 +83,15 @@ const installPreBuiltTemplates = async ( // The existing convenience endpoint `indices.putTemplate` only sends to _template, // which does not support v2 templates. // See src/core/server/elasticsearch/api_types.ts for available endpoints. - await callCluster('transport.request', callClusterParams); + return callCluster('transport.request', callClusterParams); }); + try { + return await Promise.all(templateInstallPromises); + } catch (e) { + throw new Boom(`Error installing prebuilt index templates ${e.message}`, { + statusCode: 400, + }); + } }; const installPreBuiltComponentTemplates = async ( @@ -105,7 +104,7 @@ const installPreBuiltComponentTemplates = async ( pkgVersion, (entry: Registry.ArchiveEntry) => isComponentTemplate(entry) ); - templatePaths.forEach(async path => { + const templateInstallPromises = templatePaths.map(async path => { const { file } = Registry.pathParts(path); const templateName = file.substr(0, file.lastIndexOf('.')); const content = JSON.parse(Registry.getAsset(path).toString('utf8')); @@ -124,8 +123,15 @@ const installPreBuiltComponentTemplates = async ( // This uses the catch-all endpoint 'transport.request' because there is no // convenience endpoint for component templates yet. // See src/core/server/elasticsearch/api_types.ts for available endpoints. - await callCluster('transport.request', callClusterParams); + return callCluster('transport.request', callClusterParams); }); + try { + return await Promise.all(templateInstallPromises); + } catch (e) { + throw new Boom(`Error installing prebuilt component templates ${e.message}`, { + statusCode: 400, + }); + } }; const isTemplate = ({ path }: Registry.ArchiveEntry) => { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts index 632bc3ac9b69f..79a5e98b9507d 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/packages/install.ts @@ -73,17 +73,13 @@ export async function ensureInstalledPackage(options: { if (installedPackage) { return installedPackage; } - // if the requested packaged was not found to be installed, try installing - try { - await installLatestPackage({ - savedObjectsClient, - pkgName, - callCluster, - }); - return await getInstallation({ savedObjectsClient, pkgName }); - } catch (err) { - throw new Error(err.message); - } + // if the requested packaged was not found to be installed, install + await installLatestPackage({ + savedObjectsClient, + pkgName, + callCluster, + }); + return await getInstallation({ savedObjectsClient, pkgName }); } export async function installPackage(options: { diff --git a/x-pack/plugins/ingest_manager/server/services/epm/registry/requests.ts b/x-pack/plugins/ingest_manager/server/services/epm/registry/requests.ts index 654aa8aae1355..93e475cbc5956 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/registry/requests.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/registry/requests.ts @@ -17,7 +17,7 @@ export async function getResponse(url: string): Promise { throw new Boom(response.statusText, { statusCode: response.status }); } } catch (e) { - throw Boom.boomify(e); + throw new Boom(`Error connecting to package registry: ${e.message}`, { statusCode: 502 }); } } diff --git a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_worker.ts b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_worker.ts index 7514e482783b3..0eaf3143eaac0 100644 --- a/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_worker.ts +++ b/x-pack/plugins/ml/server/routes/apidoc_scripts/schema_worker.ts @@ -44,9 +44,10 @@ export function postProcess(parsedFiles: any[]): void { */ function updateBlockParameters(docEntries: DocEntry[], block: Block, paramsGroup: string): void { if (!block.local.parameter) { - block.local.parameter = { - fields: {}, - }; + block.local.parameter = {}; + } + if (!block.local.parameter.fields) { + block.local.parameter.fields = {}; } if (!block.local.parameter.fields![paramsGroup]) { diff --git a/x-pack/plugins/ml/server/routes/modules.ts b/x-pack/plugins/ml/server/routes/modules.ts index 622ae66ede426..ade3d3eca90ea 100644 --- a/x-pack/plugins/ml/server/routes/modules.ts +++ b/x-pack/plugins/ml/server/routes/modules.ts @@ -4,13 +4,18 @@ * you may not use this file except in compliance with the Elastic License. */ -import { schema, TypeOf } from '@kbn/config-schema'; +import { TypeOf } from '@kbn/config-schema'; import { RequestHandlerContext } from 'kibana/server'; import { DatafeedOverride, JobOverride } from '../../common/types/modules'; import { wrapError } from '../client/error_wrapper'; import { DataRecognizer } from '../models/data_recognizer'; -import { getModuleIdParamSchema, setupModuleBodySchema } from './schemas/modules'; +import { + moduleIdParamSchema, + optionalModuleIdParamSchema, + modulesIndexPatternTitleSchema, + setupModuleBodySchema, +} from './schemas/modules'; import { RouteInitialization } from '../types'; function recognize(context: RequestHandlerContext, indexPatternTitle: string) { @@ -85,17 +90,33 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { * * @api {get} /api/ml/modules/recognize/:indexPatternTitle Recognize index pattern * @apiName RecognizeIndex - * @apiDescription Returns the list of modules that matching the index pattern. - * - * @apiParam {String} indexPatternTitle Index pattern title. + * @apiDescription By supplying an index pattern, discover if any of the modules are a match for data in that index. + * @apiSchema (params) modulesIndexPatternTitleSchema + * @apiSuccess {object[]} modules Array of objects describing the modules which match the index pattern. + * @apiSuccessExample {json} Success-Response: + * [{ + * "id": "nginx_ecs", + * "query": { + * "bool": { + * "filter": [ + * { "term": { "event.dataset": "nginx.access" } }, + * { "exists": { "field": "source.address" } }, + * { "exists": { "field": "url.original" } }, + * { "exists": { "field": "http.response.status_code" } } + * ] + * } + * }, + * "description": "Find unusual activity in HTTP access logs from filebeat (ECS)", + * "logo": { + * "icon": "logoNginx" + * } + * }] */ router.get( { path: '/api/ml/modules/recognize/{indexPatternTitle}', validate: { - params: schema.object({ - indexPatternTitle: schema.string(), - }), + params: modulesIndexPatternTitleSchema, }, options: { tags: ['access:ml:canCreateJob'], @@ -118,17 +139,114 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { * * @api {get} /api/ml/modules/get_module/:moduleId Get module * @apiName GetModule - * @apiDescription Returns module by id. - * - * @apiParam {String} [moduleId] Module id + * @apiDescription Retrieve a whole ML module, containing jobs, datafeeds and saved objects. If + * no module ID is supplied, returns all modules. + * @apiSchema (params) moduleIdParamSchema + * @apiSuccess {object} module When a module ID is specified, returns a module object containing + * all of the jobs, datafeeds and saved objects which will be created when the module is setup. + * @apiSuccess {object[]} modules If no module ID is supplied, an array of all modules will be returned. + * @apiSuccessExample {json} Success-Response: + * { + * "id":"sample_data_ecommerce", + * "title":"Kibana sample data eCommerce", + * "description":"Find anomalies in eCommerce total sales data", + * "type":"Sample Dataset", + * "logoFile":"logo.json", + * "defaultIndexPattern":"kibana_sample_data_ecommerce", + * "query":{ + * "bool":{ + * "filter":[ + * { + * "term":{ + * "_index":"kibana_sample_data_ecommerce" + * } + * } + * ] + * } + * }, + * "jobs":[ + * { + * "id":"high_sum_total_sales", + * "config":{ + * "groups":[ + * "kibana_sample_data", + * "kibana_sample_ecommerce" + * ], + * "description":"Find customers spending an unusually high amount in an hour", + * "analysis_config":{ + * "bucket_span":"1h", + * "detectors":[ + * { + * "detector_description":"High total sales", + * "function":"high_sum", + * "field_name":"taxful_total_price", + * "over_field_name":"customer_full_name.keyword" + * } + * ], + * "influencers":[ + * "customer_full_name.keyword", + * "category.keyword" + * ] + * }, + * "analysis_limits":{ + * "model_memory_limit":"10mb" + * }, + * "data_description":{ + * "time_field":"order_date" + * }, + * "model_plot_config":{ + * "enabled":true + * }, + * "custom_settings":{ + * "created_by":"ml-module-sample", + * "custom_urls":[ + * { + * "url_name":"Raw data", + * "url_value":"kibana#/discover?_g=(time:(from:'$earliest$',mode:absolute,to:'$latest$'))&_a + * (index:ff959d40-b880-11e8-a6d9-e546fe2bba5f,query:(language:kuery,query:'customer_full_name + * keyword:\"$customer_full_name.keyword$\"'),sort:!('@timestamp',desc))" + * }, + * { + * "url_name":"Data dashboard", + * "url_value":"kibana#/dashboard/722b74f0-b882-11e8-a6d9-e546fe2bba5f?_g=(filters:!(),time:(from:'$earliest$', + * mode:absolute,to:'$latest$'))&_a=(filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f + * index:'INDEX_PATTERN_ID', key:customer_full_name.keyword,negate:!f,params:(query:'$customer_full_name.keyword$') + * type:phrase,value:'$customer_full_name.keyword$'),query:(match:(customer_full_name.keyword: + * (query:'$customer_full_name.keyword$',type:phrase))))),query:(language:kuery, query:''))" + * } + * ] + * } + * } + * } + * ], + * "datafeeds":[ + * { + * "id":"datafeed-high_sum_total_sales", + * "config":{ + * "job_id":"high_sum_total_sales", + * "indexes":[ + * "INDEX_PATTERN_NAME" + * ], + * "query":{ + * "bool":{ + * "filter":[ + * { + * "term":{ "_index":"kibana_sample_data_ecommerce" } + * } + * ] + * } + * } + * } + * } + * ], + * "kibana":{} + * } */ router.get( { path: '/api/ml/modules/get_module/{moduleId?}', validate: { - params: schema.object({ - ...getModuleIdParamSchema(true), - }), + params: optionalModuleIdParamSchema, }, options: { tags: ['access:ml:canGetJobs'], @@ -154,17 +272,148 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { /** * @apiGroup Modules * - * @api {post} /api/ml/modules/setup/:moduleId Setup module + * @api {post} /api/ml/modules/setup/:moduleId Set up module * @apiName SetupModule - * @apiDescription Created module items. - * + * @apiDescription Runs the module setup process. + * This creates jobs, datafeeds and kibana saved objects. It allows for customization of the module, + * overriding the default configuration. It also allows the user to start the datafeed. + * @apiSchema (params) moduleIdParamSchema * @apiSchema (body) setupModuleBodySchema + * @apiParamExample {json} jobOverrides-no-job-ID: + * "jobOverrides": { + * "analysis_limits": { + * "model_memory_limit": "13mb" + * } + * } + * @apiParamExample {json} jobOverrides-with-job-ID: + * "jobOverrides": [ + * { + * "analysis_limits": { + * "job_id": "foo" + * "model_memory_limit": "13mb" + * } + * } + * ] + * @apiParamExample {json} datafeedOverrides: + * "datafeedOverrides": [ + * { + * "scroll_size": 1001 + * }, + * { + * "job_id": "visitor_rate_ecs", + * "frequency": "30m" + * } + * ] + * @apiParamExample {json} query-overrrides-datafeedOverrides-query: + * { + * "query": {"bool":{"must":[{"match_all":{}}]}} + * "datafeedOverrides": { + * "query": {} + * } + * } + * @apiSuccess {object} results An object containing the results of creating the items in a module, + * i.e. the jobs, datafeeds and saved objects. Each item is listed by id with a success flag + * signifying whether the creation was successful. If the item creation failed, an error object + * with also be supplied containing the error. + * @apiSuccessExample {json} Success-Response: + * { + * "jobs": [{ + * "id": "test-visitor_rate_ecs", + * "success": true + * }, { + * "id": "test-status_code_rate_ecs", + * "success": true + * }, { + * "id": "test-source_ip_url_count_ecs", + * "success": true + * }, { + * "id": "test-source_ip_request_rate_ecs", + * "success": true + * }, { + * "id": "test-low_request_rate_ecs", + * "success": true + * }], + * "datafeeds": [{ + * "id": "datafeed-test-visitor_rate_ecs", + * "success": true, + * "started": false + * }, { + * "id": "datafeed-test-status_code_rate_ecs", + * "success": true, + * "started": false + * }, { + * "id": "datafeed-test-source_ip_url_count_ecs", + * "success": true, + * "started": false + * }, { + * "id": "datafeed-test-low_request_rate_ecs", + * "success": true, + * "started": false + * }, { + * "id": "datafeed-test-source_ip_request_rate_ecs", + * "success": true, + * "started": false + * }], + * "kibana": { + * "dashboard": [{ + * "id": "ml_http_access_explorer_ecs", + * "success": true + * }], + * "search": [{ + * "id": "ml_http_access_filebeat_ecs", + * "success": true + * }], + * "visualization": [{ + * "id": "ml_http_access_map_ecs", + * "success": true + * }, { + * "id": "ml_http_access_source_ip_timechart_ecs", + * "success": true + * }, { + * "id": "ml_http_access_status_code_timechart_ecs", + * "success": true + * }, { + * "id": "ml_http_access_top_source_ips_table_ecs", + * "success": true + * }, { + * "id": "ml_http_access_top_urls_table_ecs", + * "success": true + * }, { + * "id": "ml_http_access_events_timechart_ecs", + * "success": true + * }, { + * "id": "ml_http_access_unique_count_url_timechart_ecs", + * "success": true + * }] + * } + * } + * @apiSuccessExample {json} Error-Response: + * { + * "jobs": [{ + * "id": "test-status_code_rate_ecs", + * "success": false, + * "error": { + * "msg": "[resource_already_exists_exception] The job cannot be created with the Id 'test-status_code_rate_ecs'. The Id is + * already used.", + * "path": "/_ml/anomaly_detectors/test-status_code_rate_ecs", + * "query": {}, + * "body": "{...}", + * "statusCode": 400, + * "response": "{\"error\":{\"root_cause\":[{\"type\":\"resource_already_exists_exception\",\"reason\":\"The job cannot be created + * with the Id 'test-status_code_rate_ecs'. The Id is already used.\"}],\"type\":\"resource_already_exists_exception\", + * \"reason\":\"The job cannot be created with the Id 'test-status_code_rate_ecs'. The Id is already used.\"},\"status\":400}" + * } + * }, + * }, + * ... + * }] + * } */ router.post( { path: '/api/ml/modules/setup/{moduleId}', validate: { - params: schema.object(getModuleIdParamSchema()), + params: moduleIdParamSchema, body: setupModuleBodySchema, }, options: { @@ -217,15 +466,58 @@ export function dataRecognizer({ router, mlLicense }: RouteInitialization) { * * @api {post} /api/ml/modules/jobs_exist/:moduleId Check if module jobs exist * @apiName CheckExistingModuleJobs - * @apiDescription Checks if the jobs in the module have been created. - * - * @apiParam {String} moduleId Module id + * @apiDescription Check whether the jobs in the module with the specified ID exist in the + * current list of jobs. The check runs a test to see if any of the jobs in existence + * have an ID which ends with the ID of each job in the module. This is done as a prefix + * may be supplied in the setup endpoint which is added to the start of the ID of every job in the module. + * @apiSchema (params) moduleIdParamSchema + * @apiSuccess {boolean} jobsExist true if all the jobs in the module have a matching job with an + * ID which ends with the job ID specified in the module, false otherwise. + * @apiSuccess {Object[]} jobs present if the jobs do all exist, with each object having keys of id, + * and optionally earliestTimestampMs, latestTimestampMs, latestResultsTimestampMs + * properties if the job has processed any data. + * @apiSuccessExample {json} Success-Response: + * { + * "jobsExist":true, + * "jobs":[ + * { + * "id":"nginx_low_request_rate_ecs", + * "earliestTimestampMs":1547016291000, + * "latestTimestampMs":1548256497000 + * "latestResultsTimestampMs":1548255600000 + * }, + * { + * "id":"nginx_source_ip_request_rate_ecs", + * "earliestTimestampMs":1547015109000, + * "latestTimestampMs":1548257222000 + * "latestResultsTimestampMs":1548255600000 + * }, + * { + * "id":"nginx_source_ip_url_count_ecs", + * "earliestTimestampMs":1547015109000, + * "latestTimestampMs":1548257222000 + * "latestResultsTimestampMs":1548255600000 + * }, + * { + * "id":"nginx_status_code_rate_ecs", + * "earliestTimestampMs":1547015109000, + * "latestTimestampMs":1548257222000 + * "latestResultsTimestampMs":1548255600000 + * }, + * { + * "id":"nginx_visitor_rate_ecs", + * "earliestTimestampMs":1547016291000, + * "latestTimestampMs":1548256497000 + * "latestResultsTimestampMs":1548255600000 + * } + * ] + * } */ router.get( { path: '/api/ml/modules/jobs_exist/{moduleId}', validate: { - params: schema.object(getModuleIdParamSchema()), + params: moduleIdParamSchema, }, options: { tags: ['access:ml:canGetJobs'], diff --git a/x-pack/plugins/ml/server/routes/schemas/modules.ts b/x-pack/plugins/ml/server/routes/schemas/modules.ts index 98e3d80f0ff84..23148c14c734e 100644 --- a/x-pack/plugins/ml/server/routes/schemas/modules.ts +++ b/x-pack/plugins/ml/server/routes/schemas/modules.ts @@ -7,24 +7,89 @@ import { schema } from '@kbn/config-schema'; export const setupModuleBodySchema = schema.object({ + /** + * Job ID prefix. This will be added to the start of the ID every job created by the module (optional). + */ prefix: schema.maybe(schema.string()), + /** + * List of group IDs. This will override the groups assigned to each job created by the module (optional). + */ groups: schema.maybe(schema.arrayOf(schema.string())), + /** + * Name of kibana index pattern. Overrides the index used in each datafeed and each index pattern + * used in the custom urls and saved objects created by the module. A matching index pattern must + * exist in kibana if the module contains custom urls or saved objects which rely on an index pattern ID. + * If the module does not contain custom urls or saved objects which require an index pattern ID, the + * indexPatternName can be any index name or pattern that will match an ES index. It can also be a comma + * separated list of names. If no indexPatternName is supplied, the default index pattern specified in + * the manifest.json will be used (optional). + */ indexPatternName: schema.maybe(schema.string()), + /** + * ES Query DSL object. Overrides the query object for each datafeed created by the module (optional). + */ query: schema.maybe(schema.any()), + /** + * Flag to specify that each job created by the module uses a dedicated index (optional). + */ useDedicatedIndex: schema.maybe(schema.boolean()), + /** + * Flag to specify that each datafeed created by the module is started once saved. Defaults to false (optional). + */ startDatafeed: schema.maybe(schema.boolean()), + /** + * Start date for datafeed. Specified in epoch seconds. Only used if startDatafeed is true. + * If not specified, a value of 0 is used i.e. start at the beginning of the data (optional). + */ start: schema.maybe(schema.number()), + /** + * End date for datafeed. Specified in epoch seconds. Only used if startDatafeed is true. + * If not specified, the datafeed will continue to run in real time (optional). + */ end: schema.maybe(schema.number()), + /** + * Partial job configuration which will override jobs contained in the module. Can be an array of objects. + * If a job_id is specified, only that job in the module will be overridden. + * Applied before any of the existing + * overridable options (e.g. useDedicatedIndex, groups, indexPatternName etc) + * and so can be overridden themselves (optional). + */ jobOverrides: schema.maybe(schema.any()), + /** + * Partial datafeed configuration which will override datafeeds contained in the module. + * Can be an array of objects. + * If a datafeed_id or a job_id is specified, + * only that datafeed in the module will be overridden. Applied before any of the existing + * overridable options (e.g. useDedicatedIndex, groups, indexPatternName etc) + * and so can be overridden themselves (optional). + */ datafeedOverrides: schema.maybe(schema.any()), /** * Indicates whether an estimate of the model memory limit - * should be made by checking the cardinality of fields in the job configurations. + * should be made by checking the cardinality of fields in the job configurations (optional). */ estimateModelMemory: schema.maybe(schema.boolean()), }); export const getModuleIdParamSchema = (optional = false) => { const stringType = schema.string(); - return { moduleId: optional ? schema.maybe(stringType) : stringType }; + return schema.object({ + /** + * ID of the module. + */ + moduleId: optional ? schema.maybe(stringType) : stringType, + }); }; + +export const optionalModuleIdParamSchema = getModuleIdParamSchema(true); + +export const moduleIdParamSchema = getModuleIdParamSchema(false); + +export const modulesIndexPatternTitleSchema = schema.object({ + /** + * Index pattern to recognize. Note that this does not need to be a Kibana + * index pattern, and can be the name of a single Elasticsearch index, + * or include a wildcard (*) to match multiple indices. + */ + indexPatternTitle: schema.string(), +}); diff --git a/x-pack/plugins/siem/cypress/integration/cases.spec.ts b/x-pack/plugins/siem/cypress/integration/cases.spec.ts index e11d76d8f608a..8f35a3209c69d 100644 --- a/x-pack/plugins/siem/cypress/integration/cases.spec.ts +++ b/x-pack/plugins/siem/cypress/integration/cases.spec.ts @@ -30,7 +30,6 @@ import { CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN, CASE_DETAILS_STATUS, CASE_DETAILS_TAGS, - CASE_DETAILS_TIMELINE_MARKDOWN, CASE_DETAILS_USER_ACTION, CASE_DETAILS_USERNAMES, PARTICIPANTS, @@ -103,13 +102,11 @@ describe('Cases', () => { .should('have.text', case1.reporter); cy.get(CASE_DETAILS_TAGS).should('have.text', expectedTags); cy.get(CASE_DETAILS_PUSH_TO_EXTERNAL_SERVICE_BTN).should('have.attr', 'disabled'); - cy.get(CASE_DETAILS_TIMELINE_MARKDOWN).then($element => { - const timelineLink = $element.prop('href').match(/http(s?):\/\/\w*:\w*(\S*)/)[0]; - openCaseTimeline(timelineLink); - cy.get(TIMELINE_TITLE).should('have.attr', 'value', case1.timeline.title); - cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', case1.timeline.description); - cy.get(TIMELINE_QUERY).should('have.attr', 'value', case1.timeline.query); - }); + openCaseTimeline(); + + cy.get(TIMELINE_TITLE).should('have.attr', 'value', case1.timeline.title); + cy.get(TIMELINE_DESCRIPTION).should('have.attr', 'value', case1.timeline.description); + cy.get(TIMELINE_QUERY).should('have.attr', 'value', case1.timeline.query); }); }); diff --git a/x-pack/plugins/siem/cypress/screens/case_details.ts b/x-pack/plugins/siem/cypress/screens/case_details.ts index 32bb64e93b05f..f2cdaa6994356 100644 --- a/x-pack/plugins/siem/cypress/screens/case_details.ts +++ b/x-pack/plugins/siem/cypress/screens/case_details.ts @@ -17,7 +17,7 @@ export const CASE_DETAILS_STATUS = '[data-test-subj="case-view-status"]'; export const CASE_DETAILS_TAGS = '[data-test-subj="case-tags"]'; -export const CASE_DETAILS_TIMELINE_MARKDOWN = '[data-test-subj="markdown-link"]'; +export const CASE_DETAILS_TIMELINE_LINK_MARKDOWN = '[data-test-subj="markdown-timeline-link"]'; export const CASE_DETAILS_USER_ACTION = '[data-test-subj="user-action-title"] .euiFlexItem'; diff --git a/x-pack/plugins/siem/cypress/tasks/case_details.ts b/x-pack/plugins/siem/cypress/tasks/case_details.ts index a28f8b8010adb..976d568ab3a91 100644 --- a/x-pack/plugins/siem/cypress/tasks/case_details.ts +++ b/x-pack/plugins/siem/cypress/tasks/case_details.ts @@ -5,10 +5,9 @@ */ import { TIMELINE_TITLE } from '../screens/timeline'; +import { CASE_DETAILS_TIMELINE_LINK_MARKDOWN } from '../screens/case_details'; -export const openCaseTimeline = (link: string) => { - cy.visit('/app/kibana'); - cy.visit(link); - cy.contains('a', 'SIEM'); +export const openCaseTimeline = () => { + cy.get(CASE_DETAILS_TIMELINE_LINK_MARKDOWN).click(); cy.get(TIMELINE_TITLE).should('exist'); }; diff --git a/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.test.tsx b/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.test.tsx new file mode 100644 index 0000000000000..27438207bed97 --- /dev/null +++ b/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.test.tsx @@ -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 React from 'react'; +import { mount } from 'enzyme'; +import { Router, mockHistory } from '../__mock__/router'; +import { UserActionMarkdown } from './user_action_markdown'; +import { TestProviders } from '../../../common/mock'; +import * as timelineHelpers from '../../../timelines/components/open_timeline/helpers'; +import { useApolloClient } from '../../../common/utils/apollo_context'; +const mockUseApolloClient = useApolloClient as jest.Mock; +jest.mock('../../../common/utils/apollo_context'); +const onChangeEditable = jest.fn(); +const onSaveContent = jest.fn(); + +const timelineId = '1e10f150-949b-11ea-b63c-2bc51864784c'; +const defaultProps = { + content: `A link to a timeline [timeline](http://localhost:5601/app/siem#/timelines?timeline=(id:'${timelineId}',isOpen:!t))`, + id: 'markdown-id', + isEditable: false, + onChangeEditable, + onSaveContent, +}; + +describe('UserActionMarkdown ', () => { + const queryTimelineByIdSpy = jest.spyOn(timelineHelpers, 'queryTimelineById'); + beforeEach(() => { + mockUseApolloClient.mockClear(); + jest.resetAllMocks(); + }); + + it('Opens timeline when timeline link clicked - isEditable: false', async () => { + const wrapper = mount( + + + + + + ); + wrapper + .find(`[data-test-subj="markdown-timeline-link"]`) + .first() + .simulate('click'); + + expect(queryTimelineByIdSpy).toBeCalledWith({ + apolloClient: mockUseApolloClient(), + timelineId, + updateIsLoading: expect.any(Function), + updateTimeline: expect.any(Function), + }); + }); + + it('Opens timeline when timeline link clicked - isEditable: true ', async () => { + const wrapper = mount( + + + + + + ); + wrapper + .find(`[data-test-subj="preview-tab"]`) + .first() + .simulate('click'); + wrapper + .find(`[data-test-subj="markdown-timeline-link"]`) + .first() + .simulate('click'); + expect(queryTimelineByIdSpy).toBeCalledWith({ + apolloClient: mockUseApolloClient(), + timelineId, + updateIsLoading: expect.any(Function), + updateTimeline: expect.any(Function), + }); + }); +}); diff --git a/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.tsx b/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.tsx index 23d8d8f1a7e68..03dd599da88e5 100644 --- a/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.tsx +++ b/x-pack/plugins/siem/public/cases/components/user_action_tree/user_action_markdown.tsx @@ -8,6 +8,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiButton } from '@elastic/e import React, { useCallback } from 'react'; import styled, { css } from 'styled-components'; +import { useDispatch } from 'react-redux'; import * as i18n from '../case_view/translations'; import { Markdown } from '../../../common/components/markdown'; import { Form, useForm, UseField } from '../../../shared_imports'; @@ -15,6 +16,13 @@ import { schema, Content } from './schema'; import { InsertTimelinePopover } from '../../../timelines/components/timeline/insert_timeline_popover'; import { useInsertTimeline } from '../../../timelines/components/timeline/insert_timeline_popover/use_insert_timeline'; import { MarkdownEditorForm } from '../../../common/components//markdown_editor/form'; +import { + dispatchUpdateTimeline, + queryTimelineById, +} from '../../../timelines/components/open_timeline/helpers'; + +import { updateIsLoading as dispatchUpdateIsLoading } from '../../../timelines/store/timeline/actions'; +import { useApolloClient } from '../../../common/utils/apollo_context'; const ContentWrapper = styled.div` ${({ theme }) => css` @@ -36,6 +44,8 @@ export const UserActionMarkdown = ({ onChangeEditable, onSaveContent, }: UserActionMarkdownProps) => { + const dispatch = useDispatch(); + const apolloClient = useApolloClient(); const { form } = useForm({ defaultValue: { content }, options: { stripEmptyFields: false }, @@ -49,6 +59,24 @@ export const UserActionMarkdown = ({ onChangeEditable(id); }, [id, onChangeEditable]); + const handleTimelineClick = useCallback( + (timelineId: string) => { + queryTimelineById({ + apolloClient, + timelineId, + updateIsLoading: ({ + id: currentTimelineId, + isLoading, + }: { + id: string; + isLoading: boolean; + }) => dispatch(dispatchUpdateIsLoading({ id: currentTimelineId, isLoading })), + updateTimeline: dispatchUpdateTimeline(dispatch), + }); + }, + [apolloClient] + ); + const handleSaveAction = useCallback(async () => { const { isValid, data } = await form.submit(); if (isValid) { @@ -98,6 +126,7 @@ export const UserActionMarkdown = ({ cancelAction: handleCancelAction, saveAction: handleSaveAction, }), + onClickTimeline: handleTimelineClick, onCursorPositionUpdate: handleCursorChange, topRightContent: ( ) : ( - + ); }; diff --git a/x-pack/plugins/siem/public/common/components/markdown/index.test.tsx b/x-pack/plugins/siem/public/common/components/markdown/index.test.tsx index 89af9202a597e..bbf59177bcf04 100644 --- a/x-pack/plugins/siem/public/common/components/markdown/index.test.tsx +++ b/x-pack/plugins/siem/public/common/components/markdown/index.test.tsx @@ -164,5 +164,37 @@ describe('Markdown', () => { expect(wrapper).toMatchSnapshot(); }); + + describe('markdown timeline links', () => { + const timelineId = '1e10f150-949b-11ea-b63c-2bc51864784c'; + const markdownWithTimelineLink = `A link to a timeline [timeline](http://localhost:5601/app/siem#/timelines?timeline=(id:'${timelineId}',isOpen:!t))`; + const onClickTimeline = jest.fn(); + beforeEach(() => { + jest.resetAllMocks(); + }); + test('it renders a timeline link without href when provided the onClickTimeline argument', () => { + const wrapper = mount( + + ); + + expect( + wrapper + .find('[data-test-subj="markdown-timeline-link"]') + .first() + .getDOMNode() + ).not.toHaveProperty('href'); + }); + test('timeline link onClick calls onClickTimeline with timelineId', () => { + const wrapper = mount( + + ); + wrapper + .find('[data-test-subj="markdown-timeline-link"]') + .first() + .simulate('click'); + + expect(onClickTimeline).toHaveBeenCalledWith(timelineId); + }); + }); }); }); diff --git a/x-pack/plugins/siem/public/common/components/markdown/index.tsx b/x-pack/plugins/siem/public/common/components/markdown/index.tsx index 8e051685af56d..1a4c9cb71a77e 100644 --- a/x-pack/plugins/siem/public/common/components/markdown/index.tsx +++ b/x-pack/plugins/siem/public/common/components/markdown/index.tsx @@ -10,6 +10,7 @@ import { EuiLink, EuiTableRow, EuiTableRowCell, EuiText, EuiToolTip } from '@ela import React from 'react'; import ReactMarkdown from 'react-markdown'; import styled, { css } from 'styled-components'; +import * as i18n from './translations'; const TableHeader = styled.thead` font-weight: bold; @@ -37,8 +38,9 @@ const REL_NOREFERRER = 'noreferrer'; export const Markdown = React.memo<{ disableLinks?: boolean; raw?: string; + onClickTimeline?: (timelineId: string) => void; size?: 'xs' | 's' | 'm'; -}>(({ disableLinks = false, raw, size = 's' }) => { +}>(({ disableLinks = false, onClickTimeline, raw, size = 's' }) => { const markdownRenderers = { root: ({ children }: { children: React.ReactNode[] }) => ( @@ -59,18 +61,33 @@ export const Markdown = React.memo<{ tableCell: ({ children }: { children: React.ReactNode[] }) => ( {children} ), - link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => ( - - - {children} - - - ), + link: ({ children, href }: { children: React.ReactNode[]; href?: string }) => { + if (onClickTimeline != null && href != null && href.indexOf(`timelines?timeline=(id:`) > -1) { + const timelineId = href.split('timelines?timeline=(id:')[1].split("'")[1] ?? ''; + return ( + + onClickTimeline(timelineId)} + data-test-subj="markdown-timeline-link" + > + {children} + + + ); + } + return ( + + + {children} + + + ); + }, blockquote: ({ children }: { children: React.ReactNode[] }) => ( {children} ), diff --git a/x-pack/plugins/siem/public/common/components/markdown/translations.ts b/x-pack/plugins/siem/public/common/components/markdown/translations.ts index cfd9e9ef1b106..4524d27739ea8 100644 --- a/x-pack/plugins/siem/public/common/components/markdown/translations.ts +++ b/x-pack/plugins/siem/public/common/components/markdown/translations.ts @@ -51,3 +51,11 @@ export const MARKDOWN_HINT_STRIKETHROUGH = i18n.translate( export const MARKDOWN_HINT_IMAGE_URL = i18n.translate('xpack.siem.markdown.hint.imageUrlLabel', { defaultMessage: '![image](url)', }); + +export const TIMELINE_ID = (timelineId: string) => + i18n.translate('xpack.siem.markdown.toolTip.timelineId', { + defaultMessage: 'Timeline id: { timelineId }', + values: { + timelineId, + }, + }); diff --git a/x-pack/plugins/siem/public/common/components/markdown_editor/form.tsx b/x-pack/plugins/siem/public/common/components/markdown_editor/form.tsx index 2ed85b04fe3f6..f9efbc5705b92 100644 --- a/x-pack/plugins/siem/public/common/components/markdown_editor/form.tsx +++ b/x-pack/plugins/siem/public/common/components/markdown_editor/form.tsx @@ -16,6 +16,7 @@ interface IMarkdownEditorForm { field: FieldHook; idAria: string; isDisabled: boolean; + onClickTimeline?: (timelineId: string) => void; onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void; placeholder?: string; topRightContent?: React.ReactNode; @@ -26,6 +27,7 @@ export const MarkdownEditorForm = ({ field, idAria, isDisabled = false, + onClickTimeline, onCursorPositionUpdate, placeholder, topRightContent, @@ -55,6 +57,7 @@ export const MarkdownEditorForm = ({ content={field.value as string} isDisabled={isDisabled} onChange={handleContentChange} + onClickTimeline={onClickTimeline} onCursorPositionUpdate={onCursorPositionUpdate} placeholder={placeholder} topRightContent={topRightContent} diff --git a/x-pack/plugins/siem/public/common/components/markdown_editor/index.tsx b/x-pack/plugins/siem/public/common/components/markdown_editor/index.tsx index 4fb7086e82b28..b0df2b6b5b60f 100644 --- a/x-pack/plugins/siem/public/common/components/markdown_editor/index.tsx +++ b/x-pack/plugins/siem/public/common/components/markdown_editor/index.tsx @@ -74,6 +74,7 @@ export const MarkdownEditor = React.memo<{ content: string; isDisabled?: boolean; onChange: (description: string) => void; + onClickTimeline?: (timelineId: string) => void; onCursorPositionUpdate?: (cursorPosition: CursorPosition) => void; placeholder?: string; }>( @@ -83,6 +84,7 @@ export const MarkdownEditor = React.memo<{ content, isDisabled = false, onChange, + onClickTimeline, placeholder, onCursorPositionUpdate, }) => { @@ -125,9 +127,10 @@ export const MarkdownEditor = React.memo<{ { id: 'preview', name: i18n.PREVIEW, + 'data-test-subj': 'preview-tab', content: ( - + ), }, diff --git a/x-pack/plugins/siem/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/siem/public/timelines/components/open_timeline/helpers.ts index df433f147490e..30a88c58afff8 100644 --- a/x-pack/plugins/siem/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/siem/public/timelines/components/open_timeline/helpers.ts @@ -189,7 +189,7 @@ export const formatTimelineResultToModel = ( export interface QueryTimelineById { apolloClient: ApolloClient | ApolloClient<{}> | undefined; - duplicate: boolean; + duplicate?: boolean; timelineId: string; onOpenTimeline?: (timeline: TimelineModel) => void; openTimeline?: boolean;