diff --git a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.callback.md b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.callback.md new file mode 100644 index 0000000000000..8ebc9068aa612 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.callback.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [AppLeaveConfirmAction](./kibana-plugin-core-public.appleaveconfirmaction.md) > [callback](./kibana-plugin-core-public.appleaveconfirmaction.callback.md) + +## AppLeaveConfirmAction.callback property + +Signature: + +```typescript +callback?: () => void; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md index 969d5ddd44c3e..8650cd9868940 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md +++ b/docs/development/core/public/kibana-plugin-core-public.appleaveconfirmaction.md @@ -18,6 +18,7 @@ export interface AppLeaveConfirmAction | Property | Type | Description | | --- | --- | --- | +| [callback](./kibana-plugin-core-public.appleaveconfirmaction.callback.md) | () => void | | | [text](./kibana-plugin-core-public.appleaveconfirmaction.text.md) | string | | | [title](./kibana-plugin-core-public.appleaveconfirmaction.title.md) | string | | | [type](./kibana-plugin-core-public.appleaveconfirmaction.type.md) | AppLeaveActionType.confirm | | diff --git a/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md b/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md index a5f8336f6424a..d86f7b7a1a5f9 100644 --- a/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md +++ b/docs/development/core/public/kibana-plugin-core-public.appleavehandler.md @@ -11,5 +11,5 @@ See [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) for Signature: ```typescript -export declare type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction; +export declare type AppLeaveHandler = (factory: AppLeaveActionFactory, nextAppId?: string) => AppLeaveAction; ``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.chartactioncontext.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.chartactioncontext.md index 1c9fc27d53f19..9447c8a4e50a7 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.chartactioncontext.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.chartactioncontext.md @@ -7,5 +7,5 @@ Signature: ```typescript -export declare type ChartActionContext = ValueClickContext | RangeSelectContext; +export declare type ChartActionContext = ValueClickContext | RangeSelectContext | RowClickContext; ``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isembeddable.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isembeddable.md new file mode 100644 index 0000000000000..ea8d3870dc055 --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isembeddable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [isEmbeddable](./kibana-plugin-plugins-embeddable-public.isembeddable.md) + +## isEmbeddable variable + +Signature: + +```typescript +isEmbeddable: (x: unknown) => x is IEmbeddable +``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isrowclicktriggercontext.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isrowclicktriggercontext.md new file mode 100644 index 0000000000000..91e0f988db69c --- /dev/null +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.isrowclicktriggercontext.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-embeddable-public](./kibana-plugin-plugins-embeddable-public.md) > [isRowClickTriggerContext](./kibana-plugin-plugins-embeddable-public.isrowclicktriggercontext.md) + +## isRowClickTriggerContext variable + +Signature: + +```typescript +isRowClickTriggerContext: (context: ChartActionContext) => context is RowClickContext +``` diff --git a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md index 06f792837e4fe..f1ea605703e59 100644 --- a/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md +++ b/docs/development/plugins/embeddable/public/kibana-plugin-plugins-embeddable-public.md @@ -78,7 +78,9 @@ | [defaultEmbeddableFactoryProvider](./kibana-plugin-plugins-embeddable-public.defaultembeddablefactoryprovider.md) | | | [EmbeddableRenderer](./kibana-plugin-plugins-embeddable-public.embeddablerenderer.md) | Helper react component to render an embeddable Can be used if you have an embeddable object or an embeddable factory Supports updating input by passing input prop | | [isContextMenuTriggerContext](./kibana-plugin-plugins-embeddable-public.iscontextmenutriggercontext.md) | | +| [isEmbeddable](./kibana-plugin-plugins-embeddable-public.isembeddable.md) | | | [isRangeSelectTriggerContext](./kibana-plugin-plugins-embeddable-public.israngeselecttriggercontext.md) | | +| [isRowClickTriggerContext](./kibana-plugin-plugins-embeddable-public.isrowclicktriggercontext.md) | | | [isValueClickTriggerContext](./kibana-plugin-plugins-embeddable-public.isvalueclicktriggercontext.md) | | | [PANEL\_BADGE\_TRIGGER](./kibana-plugin-plugins-embeddable-public.panel_badge_trigger.md) | | | [PANEL\_NOTIFICATION\_TRIGGER](./kibana-plugin-plugins-embeddable-public.panel_notification_trigger.md) | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md index fcccd3f6b9618..1565202e84674 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `ExpressionRenderHandler` class Signature: ```typescript -constructor(element: HTMLElement, { onRenderError, renderMode }?: Partial); +constructor(element: HTMLElement, { onRenderError, renderMode, hasCompatibleActions, }?: ExpressionRenderHandlerParams); ``` ## Parameters @@ -17,5 +17,5 @@ constructor(element: HTMLElement, { onRenderError, renderMode }?: PartialHTMLElement | | -| { onRenderError, renderMode } | Partial<ExpressionRenderHandlerParams> | | +| { onRenderError, renderMode, hasCompatibleActions, } | ExpressionRenderHandlerParams | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md index 12c663273bd8c..d65c06bdaed83 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.md @@ -14,7 +14,7 @@ export declare class ExpressionRenderHandler | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(element, { onRenderError, renderMode })](./kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md) | | Constructs a new instance of the ExpressionRenderHandler class | +| [(constructor)(element, { onRenderError, renderMode, hasCompatibleActions, })](./kibana-plugin-plugins-expressions-public.expressionrenderhandler._constructor_.md) | | Constructs a new instance of the ExpressionRenderHandler class | ## Properties @@ -24,7 +24,7 @@ export declare class ExpressionRenderHandler | [events$](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.events_.md) | | Observable<ExpressionRendererEvent> | | | [getElement](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.getelement.md) | | () => HTMLElement | | | [handleRenderError](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.handlerendererror.md) | | (error: ExpressionRenderError) => void | | -| [render](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md) | | (data: any, uiState?: any) => Promise<void> | | +| [render](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md) | | (value: any, uiState?: any) => Promise<void> | | | [render$](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.render_.md) | | Observable<number> | | | [update$](./kibana-plugin-plugins-expressions-public.expressionrenderhandler.update_.md) | | Observable<UpdateValue | null> | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md index dec17d60ffd14..87f378fd58344 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.expressionrenderhandler.render.md @@ -7,5 +7,5 @@ Signature: ```typescript -render: (data: any, uiState?: any) => Promise; +render: (value: any, uiState?: any) => Promise; ``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.hascompatibleactions.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.hascompatibleactions.md new file mode 100644 index 0000000000000..4d2b76cb323fb --- /dev/null +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.hascompatibleactions.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [IExpressionLoaderParams](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md) > [hasCompatibleActions](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.hascompatibleactions.md) + +## IExpressionLoaderParams.hasCompatibleActions property + +Signature: + +```typescript +hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions']; +``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md index 54eecad0deb50..22a73fff039e6 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iexpressionloaderparams.md @@ -19,6 +19,7 @@ export interface IExpressionLoaderParams | [customRenderers](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.customrenderers.md) | [] | | | [debug](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.debug.md) | boolean | | | [disableCaching](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.disablecaching.md) | boolean | | +| [hasCompatibleActions](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.hascompatibleactions.md) | ExpressionRenderHandlerParams['hasCompatibleActions'] | | | [inspectorAdapters](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.inspectoradapters.md) | Adapters | | | [onRenderError](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.onrendererror.md) | RenderErrorHandlerFnType | | | [renderMode](./kibana-plugin-plugins-expressions-public.iexpressionloaderparams.rendermode.md) | RenderMode | | diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.hascompatibleactions.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.hascompatibleactions.md new file mode 100644 index 0000000000000..d178af55ae2d9 --- /dev/null +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.hascompatibleactions.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-public](./kibana-plugin-plugins-expressions-public.md) > [IInterpreterRenderHandlers](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md) > [hasCompatibleActions](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.hascompatibleactions.md) + +## IInterpreterRenderHandlers.hasCompatibleActions property + +Signature: + +```typescript +hasCompatibleActions?: (event: any) => Promise; +``` diff --git a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md index a65e025451636..931e474a41006 100644 --- a/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md +++ b/docs/development/plugins/expressions/public/kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.md @@ -17,6 +17,7 @@ export interface IInterpreterRenderHandlers | [done](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.done.md) | () => void | Done increments the number of rendering successes | | [event](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.event.md) | (event: any) => void | | | [getRenderMode](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.getrendermode.md) | () => RenderMode | | +| [hasCompatibleActions](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.hascompatibleactions.md) | (event: any) => Promise<boolean> | | | [onDestroy](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.ondestroy.md) | (fn: () => void) => void | | | [reload](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.reload.md) | () => void | | | [uiState](./kibana-plugin-plugins-expressions-public.iinterpreterrenderhandlers.uistate.md) | PersistedState | | diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.hascompatibleactions.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.hascompatibleactions.md new file mode 100644 index 0000000000000..55419279f5d21 --- /dev/null +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.hascompatibleactions.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-expressions-server](./kibana-plugin-plugins-expressions-server.md) > [IInterpreterRenderHandlers](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md) > [hasCompatibleActions](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.hascompatibleactions.md) + +## IInterpreterRenderHandlers.hasCompatibleActions property + +Signature: + +```typescript +hasCompatibleActions?: (event: any) => Promise; +``` diff --git a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md index b1496386944fa..273703cacca06 100644 --- a/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md +++ b/docs/development/plugins/expressions/server/kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.md @@ -17,6 +17,7 @@ export interface IInterpreterRenderHandlers | [done](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.done.md) | () => void | Done increments the number of rendering successes | | [event](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.event.md) | (event: any) => void | | | [getRenderMode](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.getrendermode.md) | () => RenderMode | | +| [hasCompatibleActions](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.hascompatibleactions.md) | (event: any) => Promise<boolean> | | | [onDestroy](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.ondestroy.md) | (fn: () => void) => void | | | [reload](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.reload.md) | () => void | | | [uiState](./kibana-plugin-plugins-expressions-server.iinterpreterrenderhandlers.uistate.md) | PersistedState | | diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md index 5e10de4e0f2a5..fd1ea7df4fb74 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.md @@ -26,6 +26,7 @@ | [Action](./kibana-plugin-plugins-ui_actions-public.action.md) | | | [ActionContextMapping](./kibana-plugin-plugins-ui_actions-public.actioncontextmapping.md) | | | [ActionExecutionMeta](./kibana-plugin-plugins-ui_actions-public.actionexecutionmeta.md) | During action execution we can provide additional information, for example, trigger, that caused the action execution | +| [RowClickContext](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.md) | | | [Trigger](./kibana-plugin-plugins-ui_actions-public.trigger.md) | This is a convenience interface used to register a \*trigger\*.Trigger specifies a named anchor to which Action can be attached. When Trigger is being \*called\* it creates a Context object and passes it to the execute method of an Action.More than one action can be attached to a single trigger, in which case when trigger is \*called\* it first displays a context menu for user to pick a single action to execute. | | [TriggerContextMapping](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md) | | | [UiActionsActionDefinition](./kibana-plugin-plugins-ui_actions-public.uiactionsactiondefinition.md) | A convenience interface used to register an action. | @@ -42,6 +43,8 @@ | [ACTION\_VISUALIZE\_LENS\_FIELD](./kibana-plugin-plugins-ui_actions-public.action_visualize_lens_field.md) | | | [APPLY\_FILTER\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.apply_filter_trigger.md) | | | [applyFilterTrigger](./kibana-plugin-plugins-ui_actions-public.applyfiltertrigger.md) | | +| [ROW\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.row_click_trigger.md) | | +| [rowClickTrigger](./kibana-plugin-plugins-ui_actions-public.rowclicktrigger.md) | | | [SELECT\_RANGE\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.select_range_trigger.md) | | | [selectRangeTrigger](./kibana-plugin-plugins-ui_actions-public.selectrangetrigger.md) | | | [VALUE\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.value_click_trigger.md) | | diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.row_click_trigger.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.row_click_trigger.md new file mode 100644 index 0000000000000..3541b53ab1d61 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.row_click_trigger.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [ROW\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.row_click_trigger.md) + +## ROW\_CLICK\_TRIGGER variable + +Signature: + +```typescript +ROW_CLICK_TRIGGER = "ROW_CLICK_TRIGGER" +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.data.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.data.md new file mode 100644 index 0000000000000..1068cc9146893 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.data.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [RowClickContext](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.md) > [data](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.data.md) + +## RowClickContext.data property + +Signature: + +```typescript +data: { + rowIndex: number; + table: Datatable; + columns?: string[]; + }; +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.embeddable.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.embeddable.md new file mode 100644 index 0000000000000..e8baf44ff9cbc --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.embeddable.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [RowClickContext](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.md) > [embeddable](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.embeddable.md) + +## RowClickContext.embeddable property + +Signature: + +```typescript +embeddable?: IEmbeddable; +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.md new file mode 100644 index 0000000000000..74b55d85f10e3 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclickcontext.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [RowClickContext](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.md) + +## RowClickContext interface + +Signature: + +```typescript +export interface RowClickContext +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [data](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.data.md) | {
rowIndex: number;
table: Datatable;
columns?: string[];
} | | +| [embeddable](./kibana-plugin-plugins-ui_actions-public.rowclickcontext.embeddable.md) | IEmbeddable | | + diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclicktrigger.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclicktrigger.md new file mode 100644 index 0000000000000..aa1097d8c0864 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.rowclicktrigger.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [rowClickTrigger](./kibana-plugin-plugins-ui_actions-public.rowclicktrigger.md) + +## rowClickTrigger variable + +Signature: + +```typescript +rowClickTrigger: Trigger<'ROW_CLICK_TRIGGER'> +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md index 9db44d4dc7b05..2f0d22cf6dd74 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md @@ -16,6 +16,7 @@ export interface TriggerContextMapping | --- | --- | --- | | [""](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.__.md) | TriggerContext | | | [FILTER\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.filter_trigger.md) | ApplyGlobalFilterActionContext | | +| [ROW\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.row_click_trigger.md) | RowClickContext | | | [SELECT\_RANGE\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.select_range_trigger.md) | RangeSelectContext | | | [VALUE\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.value_click_trigger.md) | ValueClickContext | | | [VISUALIZE\_FIELD\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.visualize_field_trigger.md) | VisualizeFieldContext | | diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.row_click_trigger.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.row_click_trigger.md new file mode 100644 index 0000000000000..cf253df337378 --- /dev/null +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.triggercontextmapping.row_click_trigger.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-plugins-ui\_actions-public](./kibana-plugin-plugins-ui_actions-public.md) > [TriggerContextMapping](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.md) > [ROW\_CLICK\_TRIGGER](./kibana-plugin-plugins-ui_actions-public.triggercontextmapping.row_click_trigger.md) + +## TriggerContextMapping.ROW\_CLICK\_TRIGGER property + +Signature: + +```typescript +[ROW_CLICK_TRIGGER]: RowClickContext; +``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md index fd6ade88479af..ca999322b7a56 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md @@ -11,5 +11,5 @@ Signature: ```typescript -readonly addTriggerAction: (triggerId: T, action: ActionDefinition | Action) => void; +readonly addTriggerAction: (triggerId: T, action: ActionDefinition | Action) => void; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md index 19f215a96b23b..e95e7e1eb38b6 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly attachAction: (triggerId: T, actionId: string) => void; +readonly attachAction: (triggerId: T, actionId: string) => void; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md index 1bb6ca1115248..8e7fb8b8bbf29 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md @@ -12,5 +12,5 @@ Signature: ```typescript -readonly executeTriggerActions: (triggerId: T, context: TriggerContext) => Promise; +readonly executeTriggerActions: (triggerId: T, context: TriggerContext) => Promise; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md index d44dc4e43a52e..b996620686a28 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly getTrigger: (triggerId: T) => TriggerContract; +readonly getTrigger: (triggerId: T) => TriggerContract; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md index 0a9b674a45de2..f94b34ecc2d90 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly getTriggerActions: (triggerId: T) => Action[]; +readonly getTriggerActions: (triggerId: T) => Action[]; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md index faed81236342d..dff958608ef9e 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md @@ -7,5 +7,5 @@ Signature: ```typescript -readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; +readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; ``` diff --git a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md index e3c5dbb92ae90..e35eb503ab62b 100644 --- a/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md +++ b/docs/development/plugins/ui_actions/public/kibana-plugin-plugins-ui_actions-public.uiactionsservice.md @@ -21,17 +21,17 @@ export declare class UiActionsService | Property | Modifiers | Type | Description | | --- | --- | --- | --- | | [actions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.actions.md) | | ActionRegistry | | -| [addTriggerAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, action: ActionDefinition<TriggerContextMapping[T]> | Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">) => void | addTriggerAction is similar to attachAction as it attaches action to a trigger, but it also registers the action, if it has not been registered, yet.addTriggerAction also infers better typing of the action argument. | -| [attachAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, actionId: string) => void | | +| [addTriggerAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.addtriggeraction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, action: ActionDefinition<TriggerContextMapping[T]> | Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">) => void | addTriggerAction is similar to attachAction as it attaches action to a trigger, but it also registers the action, if it has not been registered, yet.addTriggerAction also infers better typing of the action argument. | +| [attachAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.attachaction.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, actionId: string) => void | | | [clear](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.clear.md) | | () => void | Removes all registered triggers and actions. | | [detachAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.detachaction.md) | | (triggerId: TriggerId, actionId: string) => void | | -| [executeTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContext<T>) => Promise<void> | | +| [executeTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.executetriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContext<T>) => Promise<void> | | | [executionService](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.executionservice.md) | | UiActionsExecutionService | | | [fork](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.fork.md) | | () => UiActionsService | "Fork" a separate instance of UiActionsService that inherits all existing triggers and actions, but going forward all new triggers and actions added to this instance of UiActionsService are only available within this instance. | | [getAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.getaction.md) | | <T extends ActionDefinition<{}>>(id: string) => Action<ActionContext<T>, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV"> | | -| [getTrigger](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => TriggerContract<T> | | -| [getTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">[] | | -| [getTriggerCompatibleActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContextMapping[T]) => Promise<Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">[]> | | +| [getTrigger](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettrigger.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => TriggerContract<T> | | +| [getTriggerActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggeractions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T) => Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">[] | | +| [getTriggerCompatibleActions](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.gettriggercompatibleactions.md) | | <T extends "" | "SELECT_RANGE_TRIGGER" | "VALUE_CLICK_TRIGGER" | "ROW_CLICK_TRIGGER" | "FILTER_TRIGGER" | "VISUALIZE_FIELD_TRIGGER" | "VISUALIZE_GEO_FIELD_TRIGGER" | "CONTEXT_MENU_TRIGGER" | "PANEL_BADGE_TRIGGER" | "PANEL_NOTIFICATION_TRIGGER">(triggerId: T, context: TriggerContextMapping[T]) => Promise<Action<TriggerContextMapping[T], "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV">[]> | | | [hasAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.hasaction.md) | | (actionId: string) => boolean | | | [registerAction](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.registeraction.md) | | <A extends ActionDefinition<{}>>(definition: A) => Action<ActionContext<A>, "" | "ACTION_VISUALIZE_FIELD" | "ACTION_VISUALIZE_GEO_FIELD" | "ACTION_VISUALIZE_LENS_FIELD" | "ACTION_GLOBAL_APPLY_FILTER" | "ACTION_SELECT_RANGE" | "ACTION_VALUE_CLICK" | "ACTION_CUSTOMIZE_PANEL" | "ACTION_ADD_PANEL" | "openInspector" | "deletePanel" | "editPanel" | "togglePanel" | "replacePanel" | "clonePanel" | "addToFromLibrary" | "unlinkFromLibrary" | "ACTION_LIBRARY_NOTIFICATION" | "ACTION_EXPORT_CSV"> | | | [registerTrigger](./kibana-plugin-plugins-ui_actions-public.uiactionsservice.registertrigger.md) | | (trigger: Trigger) => void | | diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc index 6cd848e963431..8b50fc38167d3 100644 --- a/docs/setup/settings.asciidoc +++ b/docs/setup/settings.asciidoc @@ -453,11 +453,11 @@ deprecation warning at startup. This setting cannot end in a slash (`/`). | `server.cors.enabled:` | experimental[] Set to `true` to allow cross-origin API calls. *Default:* `false` -| `server.cors.credentials:` +| `server.cors.allowCredentials:` | experimental[] Set to `true` to allow browser code to access response body whenever request performed with user credentials. *Default:* `false` -| `server.cors.origin:` - | experimental[] List of origins permitted to access resources. You must specify explicit hostnames and not use `*` for `server.cors.origin` when `server.cors.credentials: true`. *Default:* "*" +| `server.cors.allowOrigin:` + | experimental[] List of origins permitted to access resources. You must specify explicit hostnames and not use `server.cors.allowOrigin: ["*"]` when `server.cors.allowCredentials: true`. *Default:* ["*"] | `server.compression.referrerWhitelist:` | Specifies an array of trusted hostnames, such as the {kib} host, or a reverse diff --git a/docs/user/dashboard/drilldowns.asciidoc b/docs/user/dashboard/drilldowns.asciidoc index ff2c321f667c8..3db5bd6d97ff0 100644 --- a/docs/user/dashboard/drilldowns.asciidoc +++ b/docs/user/dashboard/drilldowns.asciidoc @@ -233,7 +233,7 @@ image:images/url_drilldown_go_to_github.gif[Drilldown on pie chart that navigate https://github.com/elastic/kibana/issues?q=is:issue+is:open+{{event.value}} ---- + -The example URL navigates to {kib} issues on Github. `{{event.value}}` is substituted with a value associated with a selected pie slice. In *URL preview*, `{{event.value}}` is substituted with a <> value. +The example URL navigates to {kib} issues on Github. `{{event.value}}` is substituted with a value associated with a selected pie slice. + [role="screenshot"] image:images/url_drilldown_url_template.png[URL template input] diff --git a/docs/user/dashboard/images/url_drilldown_url_template.png b/docs/user/dashboard/images/url_drilldown_url_template.png index d8515afe66a80..746ce62733618 100644 Binary files a/docs/user/dashboard/images/url_drilldown_url_template.png and b/docs/user/dashboard/images/url_drilldown_url_template.png differ diff --git a/docs/user/dashboard/url-drilldown.asciidoc b/docs/user/dashboard/url-drilldown.asciidoc index 617fae938f8f5..df9fa2dca81fd 100644 --- a/docs/user/dashboard/url-drilldown.asciidoc +++ b/docs/user/dashboard/url-drilldown.asciidoc @@ -146,17 +146,7 @@ The URL drilldown template has three sources for variables: * *Context* variables that change depending on where the drilldown is created and used. These variables are extracted from a context of a panel on a dashboard. For example, `{{context.panel.filters}}` gives access to filters that applied to the current panel. * *Event* variables that depend on the trigger context. These variables are dynamically extracted from the interaction context when the drilldown is executed. -[[values-in-preview]] -A subtle but important difference between *context* and *event* variables is that *context* variables use real values in previews when creating a URL drilldown. -For example, `{{context.panel.filters}}` are previewed with the current filters that applied to a panel. -*Event* variables are extracted during drilldown execution from a user interaction with a panel (for example, from a pie slice that the user clicked on). - -Because there is no user interaction with a panel in preview, there is no interaction context to use in a preview. -To work around this, {kib} provides a sample interaction that relies on a trigger. -So in a preview, you might notice that `{{event.value}}` is replaced with `{{event.value}}` instead of with a sample from your data. -Such previews can help you make sure that the structure of your URL template is valid. -However, to ensure that the configured URL drilldown works as expected with your data, you have to save the dashboard and test in the panel. - +To ensure that the configured URL drilldown works as expected with your data, you have to save the dashboard and test in the panel. You can access the full list of variables available for the current panel and selected trigger by clicking *Add variable* in the top-right corner of a URL template input. [float] @@ -241,6 +231,22 @@ Note: `{{event.value}}` is a shorthand for `{{event.points.[0].value}}` + `{{event.key}}` is a shorthand for `{{event.points.[0].key}}` +| *Row click* +| event.rowIndex +| Number, representing the row that was clicked, starting from 0. + +| +| event.values +| An array of all cell values for the raw on which the action will execute. + +| +| event.keys +| An array of field names for each column. + +| +| event.columnNames +| An array of column names. + | *Range selection* | event.from + event.to diff --git a/packages/kbn-optimizer/limits.yml b/packages/kbn-optimizer/limits.yml index 27d7f1af89275..95c425a81c5c9 100644 --- a/packages/kbn-optimizer/limits.yml +++ b/packages/kbn-optimizer/limits.yml @@ -83,10 +83,10 @@ pageLoadAssetSize: transform: 41007 triggersActionsUi: 170001 uiActions: 97717 - uiActionsEnhanced: 349511 + uiActionsEnhanced: 313011 upgradeAssistant: 81241 uptime: 40825 - urlDrilldown: 34174 + urlDrilldown: 70674 urlForwarding: 32579 usageCollection: 39762 visDefaultEditor: 50178 diff --git a/src/core/public/application/application_leave.test.ts b/src/core/public/application/application_leave.test.ts index b560bbc0cbc25..9d0da6fe0096d 100644 --- a/src/core/public/application/application_leave.test.ts +++ b/src/core/public/application/application_leave.test.ts @@ -35,7 +35,18 @@ describe('getLeaveAction', () => { type: AppLeaveActionType.default, }); }); + + it('returns the default action provided by the handle and nextAppId', () => { + expect(getLeaveAction((actions) => actions.default(), 'futureAppId')).toEqual({ + type: AppLeaveActionType.default, + }); + }); + it('returns the confirm action provided by the handler', () => { + expect(getLeaveAction((actions) => actions.confirm('some message'), 'futureAppId')).toEqual({ + type: AppLeaveActionType.confirm, + text: 'some message', + }); expect(getLeaveAction((actions) => actions.confirm('some message'))).toEqual({ type: AppLeaveActionType.confirm, text: 'some message', @@ -45,5 +56,14 @@ describe('getLeaveAction', () => { text: 'another message', title: 'a title', }); + const callback = jest.fn(); + expect( + getLeaveAction((actions) => actions.confirm('another message', 'a title', callback)) + ).toEqual({ + type: AppLeaveActionType.confirm, + text: 'another message', + title: 'a title', + callback, + }); }); }); diff --git a/src/core/public/application/application_leave.tsx b/src/core/public/application/application_leave.tsx index 7b69d70d3f6f6..e6170daaff0a0 100644 --- a/src/core/public/application/application_leave.tsx +++ b/src/core/public/application/application_leave.tsx @@ -26,8 +26,8 @@ import { } from './types'; const appLeaveActionFactory: AppLeaveActionFactory = { - confirm(text: string, title?: string) { - return { type: AppLeaveActionType.confirm, text, title }; + confirm(text: string, title?: string, callback?: () => void) { + return { type: AppLeaveActionType.confirm, text, title, callback }; }, default() { return { type: AppLeaveActionType.default }; @@ -38,9 +38,9 @@ export function isConfirmAction(action: AppLeaveAction): action is AppLeaveConfi return action.type === AppLeaveActionType.confirm; } -export function getLeaveAction(handler?: AppLeaveHandler): AppLeaveAction { +export function getLeaveAction(handler?: AppLeaveHandler, nextAppId?: string): AppLeaveAction { if (!handler) { return appLeaveActionFactory.default(); } - return handler(appLeaveActionFactory); + return handler(appLeaveActionFactory, nextAppId); } diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index cd186f87b3a87..912ab40cbe1db 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -755,6 +755,19 @@ describe('#start()', () => { `); }); + it('should call private function shouldNavigate with overlays and the nextAppId', async () => { + service.setup(setupDeps); + const shouldNavigateSpy = jest.spyOn(service as any, 'shouldNavigate'); + + const { navigateToApp } = await service.start(startDeps); + + await navigateToApp('myTestApp'); + expect(shouldNavigateSpy).toHaveBeenCalledWith(startDeps.overlays, 'myTestApp'); + + await navigateToApp('myOtherApp'); + expect(shouldNavigateSpy).toHaveBeenCalledWith(startDeps.overlays, 'myOtherApp'); + }); + describe('when `replace` option is true', () => { it('use `history.replace` instead of `history.push`', async () => { service.setup(setupDeps); diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 4d54d4831698b..67281170957c6 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -244,7 +244,9 @@ export class ApplicationService { ) => { const currentAppId = this.currentAppId$.value; const navigatingToSameApp = currentAppId === appId; - const shouldNavigate = navigatingToSameApp ? true : await this.shouldNavigate(overlays); + const shouldNavigate = navigatingToSameApp + ? true + : await this.shouldNavigate(overlays, appId); if (shouldNavigate) { if (path === undefined) { @@ -332,18 +334,24 @@ export class ApplicationService { this.currentActionMenu$.next(currentActionMenu); }; - private async shouldNavigate(overlays: OverlayStart): Promise { + private async shouldNavigate(overlays: OverlayStart, nextAppId: string): Promise { const currentAppId = this.currentAppId$.value; if (currentAppId === undefined) { return true; } - const action = getLeaveAction(this.appInternalStates.get(currentAppId)?.leaveHandler); + const action = getLeaveAction( + this.appInternalStates.get(currentAppId)?.leaveHandler, + nextAppId + ); if (isConfirmAction(action)) { const confirmed = await overlays.openConfirm(action.text, { title: action.title, 'data-test-subj': 'appLeaveConfirmModal', }); if (!confirmed) { + if (action.callback) { + setTimeout(action.callback, 0); + } return false; } } diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index d9f326c7a59ab..c161a7f166541 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -578,7 +578,10 @@ export interface AppMountParameters { * * @public */ -export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction; +export type AppLeaveHandler = ( + factory: AppLeaveActionFactory, + nextAppId?: string +) => AppLeaveAction; /** * Possible type of actions on application leave. @@ -614,6 +617,7 @@ export interface AppLeaveConfirmAction { type: AppLeaveActionType.confirm; text: string; title?: string; + callback?: () => void; } /** @@ -636,8 +640,10 @@ export interface AppLeaveActionFactory { * * @param text The text to display in the confirmation message * @param title (optional) title to display in the confirmation message + * @param callback (optional) to know that the user want to stay on the page + * so we can show to the user the right UX for him to saved his/her/their changes */ - confirm(text: string, title?: string): AppLeaveConfirmAction; + confirm(text: string, title?: string, callback?: () => void): AppLeaveConfirmAction; /** * Returns a default action, resulting on executing the default behavior when * the user tries to leave an application diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index f48fb9092d56d..3852792547062 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -91,6 +91,8 @@ export enum AppLeaveActionType { // // @public export interface AppLeaveConfirmAction { + // (undocumented) + callback?: () => void; // (undocumented) text: string; // (undocumented) @@ -110,7 +112,7 @@ export interface AppLeaveDefaultAction { // Warning: (ae-forgotten-export) The symbol "AppLeaveActionFactory" needs to be exported by the entry point index.d.ts // // @public -export type AppLeaveHandler = (factory: AppLeaveActionFactory) => AppLeaveAction; +export type AppLeaveHandler = (factory: AppLeaveActionFactory, nextAppId?: string) => AppLeaveAction; // @public (undocumented) export interface ApplicationSetup { diff --git a/src/core/server/http/__snapshots__/http_config.test.ts.snap b/src/core/server/http/__snapshots__/http_config.test.ts.snap index a440c67944fab..9b667f888771e 100644 --- a/src/core/server/http/__snapshots__/http_config.test.ts.snap +++ b/src/core/server/http/__snapshots__/http_config.test.ts.snap @@ -39,9 +39,11 @@ Object { "enabled": true, }, "cors": Object { - "credentials": false, + "allowCredentials": false, + "allowOrigin": Array [ + "*", + ], "enabled": false, - "origin": "*", }, "customResponseHeaders": Object {}, "host": "localhost", diff --git a/src/core/server/http/http_config.test.ts b/src/core/server/http/http_config.test.ts index f893e7783ac8f..b71763e8a2e14 100644 --- a/src/core/server/http/http_config.test.ts +++ b/src/core/server/http/http_config.test.ts @@ -331,51 +331,67 @@ describe('with compression', () => { }); describe('cors', () => { - describe('origin', () => { + describe('allowOrigin', () => { it('list cannot be empty', () => { expect(() => config.schema.validate({ cors: { - origin: [], + allowOrigin: [], }, }) ).toThrowErrorMatchingInlineSnapshot(` - "[cors.origin]: types that failed validation: - - [cors.origin.0]: expected value to equal [*] - - [cors.origin.1]: array size is [0], but cannot be smaller than [1]" - `); + "[cors.allowOrigin]: types that failed validation: + - [cors.allowOrigin.0]: array size is [0], but cannot be smaller than [1] + - [cors.allowOrigin.1]: array size is [0], but cannot be smaller than [1]" + `); }); it('list of valid URLs', () => { - const origin = ['http://127.0.0.1:3000', 'https://elastic.co']; + const allowOrigin = ['http://127.0.0.1:3000', 'https://elastic.co']; expect( config.schema.validate({ - cors: { origin }, - }).cors.origin - ).toStrictEqual(origin); + cors: { allowOrigin }, + }).cors.allowOrigin + ).toStrictEqual(allowOrigin); expect(() => config.schema.validate({ cors: { - origin: ['*://elastic.co/*'], + allowOrigin: ['*://elastic.co/*'], }, }) ).toThrow(); }); it('can be configured as "*" wildcard', () => { - expect(config.schema.validate({ cors: { origin: '*' } }).cors.origin).toBe('*'); + expect(config.schema.validate({ cors: { allowOrigin: ['*'] } }).cors.allowOrigin).toEqual([ + '*', + ]); + }); + + it('cannot mix wildcard "*" with valid URLs', () => { + expect( + () => + config.schema.validate({ cors: { allowOrigin: ['*', 'https://elastic.co'] } }).cors + .allowOrigin + ).toThrowErrorMatchingInlineSnapshot(` + "[cors.allowOrigin]: types that failed validation: + - [cors.allowOrigin.0.0]: expected URI with scheme [http|https]. + - [cors.allowOrigin.1.1]: expected value to equal [*]" + `); }); }); describe('credentials', () => { - it('cannot use wildcard origin if "credentials: true"', () => { + it('cannot use wildcard allowOrigin if "credentials: true"', () => { expect( - () => config.schema.validate({ cors: { credentials: true, origin: '*' } }).cors.origin + () => + config.schema.validate({ cors: { allowCredentials: true, allowOrigin: ['*'] } }).cors + .allowOrigin ).toThrowErrorMatchingInlineSnapshot( `"[cors]: Cannot specify wildcard origin \\"*\\" with \\"credentials: true\\". Please provide a list of allowed origins."` ); expect( - () => config.schema.validate({ cors: { credentials: true } }).cors.origin + () => config.schema.validate({ cors: { allowCredentials: true } }).cors.allowOrigin ).toThrowErrorMatchingInlineSnapshot( `"[cors]: Cannot specify wildcard origin \\"*\\" with \\"credentials: true\\". Please provide a list of allowed origins."` ); diff --git a/src/core/server/http/http_config.ts b/src/core/server/http/http_config.ts index 74cdbfbedeea9..2bd296fe338ab 100644 --- a/src/core/server/http/http_config.ts +++ b/src/core/server/http/http_config.ts @@ -48,17 +48,20 @@ export const config = { cors: schema.object( { enabled: schema.boolean({ defaultValue: false }), - credentials: schema.boolean({ defaultValue: false }), - origin: schema.oneOf( - [schema.literal('*'), schema.arrayOf(hostURISchema, { minSize: 1 })], + allowCredentials: schema.boolean({ defaultValue: false }), + allowOrigin: schema.oneOf( + [ + schema.arrayOf(hostURISchema, { minSize: 1 }), + schema.arrayOf(schema.literal('*'), { minSize: 1, maxSize: 1 }), + ], { - defaultValue: '*', + defaultValue: ['*'], } ), }, { validate(value) { - if (value.credentials === true && value.origin === '*') { + if (value.allowCredentials === true && value.allowOrigin.includes('*')) { return 'Cannot specify wildcard origin "*" with "credentials: true". Please provide a list of allowed origins.'; } }, @@ -168,8 +171,8 @@ export class HttpConfig { public port: number; public cors: { enabled: boolean; - credentials: boolean; - origin: '*' | string[]; + allowCredentials: boolean; + allowOrigin: string[]; }; public customResponseHeaders: Record; public maxPayload: ByteSizeValue; diff --git a/src/core/server/http/http_tools.test.ts b/src/core/server/http/http_tools.test.ts index 4098b631b19d8..962c2107513b5 100644 --- a/src/core/server/http/http_tools.test.ts +++ b/src/core/server/http/http_tools.test.ts @@ -196,8 +196,8 @@ describe('getServerOptions', () => { config.schema.validate({ cors: { enabled: true, - credentials: false, - origin: '*', + allowCredentials: false, + allowOrigin: ['*'], }, }), {} as any, @@ -206,7 +206,7 @@ describe('getServerOptions', () => { expect(getServerOptions(httpConfig).routes?.cors).toEqual({ credentials: false, - origin: '*', + origin: ['*'], headers: ['Accept', 'Authorization', 'Content-Type', 'If-None-Match', 'kbn-xsrf'], }); }); diff --git a/src/core/server/http/http_tools.ts b/src/core/server/http/http_tools.ts index 61688a51345b5..8bec26f31fa26 100644 --- a/src/core/server/http/http_tools.ts +++ b/src/core/server/http/http_tools.ts @@ -39,8 +39,8 @@ const corsAllowedHeaders = ['Accept', 'Authorization', 'Content-Type', 'If-None- export function getServerOptions(config: HttpConfig, { configureTLS = true } = {}) { const cors: RouteOptionsCors | false = config.cors.enabled ? { - credentials: config.cors.credentials, - origin: config.cors.origin, + credentials: config.cors.allowCredentials, + origin: config.cors.allowOrigin, headers: corsAllowedHeaders, } : false; diff --git a/src/plugins/embeddable/public/index.ts b/src/plugins/embeddable/public/index.ts index 0d2dcf208f2ef..0fc7c7965010b 100644 --- a/src/plugins/embeddable/public/index.ts +++ b/src/plugins/embeddable/public/index.ts @@ -54,6 +54,7 @@ export { ErrorEmbeddable, IContainer, IEmbeddable, + isEmbeddable, isErrorEmbeddable, openAddPanelFlyout, OutputSpec, @@ -70,6 +71,7 @@ export { isSavedObjectEmbeddableInput, isRangeSelectTriggerContext, isValueClickTriggerContext, + isRowClickTriggerContext, isContextMenuTriggerContext, EmbeddableStateTransfer, EmbeddableEditorState, diff --git a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts index 5a73df2e13861..a19ec2345db8d 100644 --- a/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts +++ b/src/plugins/embeddable/public/lib/embeddables/i_embeddable.ts @@ -33,7 +33,7 @@ export { EmbeddableInput }; export interface EmbeddableOutput { // Whether the embeddable is actively loading. loading?: boolean; - // Whether the embeddable finshed loading with an error. + // Whether the embeddable finished loading with an error. error?: EmbeddableError; editUrl?: string; editApp?: string; diff --git a/src/plugins/embeddable/public/lib/embeddables/index.ts b/src/plugins/embeddable/public/lib/embeddables/index.ts index 2f6de1be60c9c..4c2baa3bbf809 100644 --- a/src/plugins/embeddable/public/lib/embeddables/index.ts +++ b/src/plugins/embeddable/public/lib/embeddables/index.ts @@ -17,6 +17,7 @@ * under the License. */ export { EmbeddableOutput, EmbeddableInput, IEmbeddable } from './i_embeddable'; +export { isEmbeddable } from './is_embeddable'; export { Embeddable } from './embeddable'; export * from './embeddable_factory'; export * from './embeddable_factory_definition'; diff --git a/src/test_utils/jest.config.js b/src/plugins/embeddable/public/lib/embeddables/is_embeddable.ts similarity index 66% rename from src/test_utils/jest.config.js rename to src/plugins/embeddable/public/lib/embeddables/is_embeddable.ts index b7e77413598c0..e660fdbc4472c 100644 --- a/src/test_utils/jest.config.js +++ b/src/plugins/embeddable/public/lib/embeddables/is_embeddable.ts @@ -17,8 +17,13 @@ * under the License. */ -module.exports = { - preset: '@kbn/test', - rootDir: '../..', - roots: ['/src/test_utils'], +import { IEmbeddable } from './i_embeddable'; + +export const isEmbeddable = (x: unknown): x is IEmbeddable => { + if (!x) return false; + if (typeof x !== 'object') return false; + if (typeof (x as IEmbeddable).id !== 'string') return false; + if (typeof (x as IEmbeddable).getInput !== 'function') return false; + if (typeof (x as IEmbeddable).supportedTriggers !== 'function') return false; + return true; }; diff --git a/src/plugins/embeddable/public/lib/triggers/triggers.ts b/src/plugins/embeddable/public/lib/triggers/triggers.ts index b2965b55dbdfa..c3b1496b8eca8 100644 --- a/src/plugins/embeddable/public/lib/triggers/triggers.ts +++ b/src/plugins/embeddable/public/lib/triggers/triggers.ts @@ -19,7 +19,7 @@ import { i18n } from '@kbn/i18n'; import { Datatable } from '../../../../expressions'; -import { Trigger } from '../../../../ui_actions/public'; +import { Trigger, RowClickContext } from '../../../../ui_actions/public'; import { IEmbeddable } from '..'; export interface EmbeddableContext { @@ -52,7 +52,8 @@ export interface RangeSelectContext { export type ChartActionContext = | ValueClickContext - | RangeSelectContext; + | RangeSelectContext + | RowClickContext; export const CONTEXT_MENU_TRIGGER = 'CONTEXT_MENU_TRIGGER'; export const contextMenuTrigger: Trigger<'CONTEXT_MENU_TRIGGER'> = { @@ -95,6 +96,11 @@ export const isRangeSelectTriggerContext = ( context: ChartActionContext ): context is RangeSelectContext => context.data && 'range' in context.data; +export const isRowClickTriggerContext = (context: ChartActionContext): context is RowClickContext => + !!context.data && + typeof context.data === 'object' && + typeof (context as RowClickContext).data.rowIndex === 'number'; + export const isContextMenuTriggerContext = (context: unknown): context is EmbeddableContext => !!context && typeof context === 'object' && diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index e42eaaf86bdf3..4b7d60b4dc9ec 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -176,10 +176,11 @@ export class AttributeService>; } +// Warning: (ae-forgotten-export) The symbol "RowClickContext" needs to be exported by the entry point index.d.ts // Warning: (ae-missing-release-tag) "ChartActionContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) -export type ChartActionContext = ValueClickContext | RangeSelectContext; +export type ChartActionContext = ValueClickContext | RangeSelectContext | RowClickContext; // Warning: (ae-missing-release-tag) "Container" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // @@ -726,6 +727,11 @@ export interface IEmbeddable context is EmbeddableContext; +// Warning: (ae-missing-release-tag) "isEmbeddable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const isEmbeddable: (x: unknown) => x is IEmbeddable; + // Warning: (ae-missing-release-tag) "isErrorEmbeddable" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -741,6 +747,11 @@ export const isRangeSelectTriggerContext: (context: ChartActionContext) => conte // @public (undocumented) export function isReferenceOrValueEmbeddable(incoming: unknown): incoming is ReferenceOrValueEmbeddable; +// Warning: (ae-missing-release-tag) "isRowClickTriggerContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const isRowClickTriggerContext: (context: ChartActionContext) => context is RowClickContext; + // Warning: (ae-missing-release-tag) "isSavedObjectEmbeddableInput" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) diff --git a/src/plugins/expressions/common/expression_renderers/types.ts b/src/plugins/expressions/common/expression_renderers/types.ts index dd3124c7d17ee..88aca4c07ee31 100644 --- a/src/plugins/expressions/common/expression_renderers/types.ts +++ b/src/plugins/expressions/common/expression_renderers/types.ts @@ -82,6 +82,7 @@ export interface IInterpreterRenderHandlers { reload: () => void; update: (params: any) => void; event: (event: any) => void; + hasCompatibleActions?: (event: any) => Promise; getRenderMode: () => RenderMode; uiState?: PersistedState; } diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts index 983a344c0e1a1..e9e0fa18af6c3 100644 --- a/src/plugins/expressions/public/loader.ts +++ b/src/plugins/expressions/public/loader.ts @@ -64,6 +64,7 @@ export class ExpressionLoader { this.renderHandler = new ExpressionRenderHandler(element, { onRenderError: params && params.onRenderError, renderMode: params?.renderMode, + hasCompatibleActions: params?.hasCompatibleActions, }); this.render$ = this.renderHandler.render$; this.update$ = this.renderHandler.update$; diff --git a/src/plugins/expressions/public/public.api.md b/src/plugins/expressions/public/public.api.md index 97ff00db0966c..6eb0e71c58e3f 100644 --- a/src/plugins/expressions/public/public.api.md +++ b/src/plugins/expressions/public/public.api.md @@ -532,7 +532,7 @@ export interface ExpressionRenderError extends Error { // @public (undocumented) export class ExpressionRenderHandler { // Warning: (ae-forgotten-export) The symbol "ExpressionRenderHandlerParams" needs to be exported by the entry point index.d.ts - constructor(element: HTMLElement, { onRenderError, renderMode }?: Partial); + constructor(element: HTMLElement, { onRenderError, renderMode, hasCompatibleActions, }?: ExpressionRenderHandlerParams); // (undocumented) destroy: () => void; // (undocumented) @@ -544,7 +544,7 @@ export class ExpressionRenderHandler { // (undocumented) render$: Observable; // (undocumented) - render: (data: any, uiState?: any) => Promise; + render: (value: any, uiState?: any) => Promise; // Warning: (ae-forgotten-export) The symbol "UpdateValue" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -888,6 +888,8 @@ export interface IExpressionLoaderParams { // (undocumented) disableCaching?: boolean; // (undocumented) + hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions']; + // (undocumented) inspectorAdapters?: Adapters; // Warning: (ae-forgotten-export) The symbol "RenderErrorHandlerFnType" needs to be exported by the entry point index.d.ts // @@ -917,6 +919,8 @@ export interface IInterpreterRenderHandlers { // (undocumented) getRenderMode: () => RenderMode; // (undocumented) + hasCompatibleActions?: (event: any) => Promise; + // (undocumented) onDestroy: (fn: () => void) => void; // (undocumented) reload: () => void; diff --git a/src/plugins/expressions/public/render.test.ts b/src/plugins/expressions/public/render.test.ts index c44683f6779c0..3fc0100db489d 100644 --- a/src/plugins/expressions/public/render.test.ts +++ b/src/plugins/expressions/public/render.test.ts @@ -126,6 +126,31 @@ describe('ExpressionRenderHandler', () => { expect(getHandledError()!.message).toEqual('renderer error'); }); + it('should pass through provided "hasCompatibleActions" to the expression renderer', async () => { + const hasCompatibleActions = jest.fn(); + (getRenderersRegistry as jest.Mock).mockReturnValueOnce({ get: () => true }); + (getRenderersRegistry as jest.Mock).mockReturnValueOnce({ + get: () => ({ + render: (domNode: HTMLElement, config: unknown, handlers: IInterpreterRenderHandlers) => { + handlers.hasCompatibleActions!({ + foo: 'bar', + }); + }, + }), + }); + + const expressionRenderHandler = new ExpressionRenderHandler(element, { + onRenderError: mockMockErrorRenderFunction, + hasCompatibleActions, + }); + expect(hasCompatibleActions).toHaveBeenCalledTimes(0); + await expressionRenderHandler.render({ type: 'render', as: 'something' }); + expect(hasCompatibleActions).toHaveBeenCalledTimes(1); + expect(hasCompatibleActions.mock.calls[0][0]).toEqual({ + foo: 'bar', + }); + }); + it('sends a next observable once rendering is complete', () => { const expressionRenderHandler = new ExpressionRenderHandler(element); expect.assertions(1); diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts index 4390033b5be60..717776a2861b4 100644 --- a/src/plugins/expressions/public/render.ts +++ b/src/plugins/expressions/public/render.ts @@ -29,8 +29,9 @@ import { getRenderersRegistry } from './services'; export type IExpressionRendererExtraHandlers = Record; export interface ExpressionRenderHandlerParams { - onRenderError: RenderErrorHandlerFnType; - renderMode: RenderMode; + onRenderError?: RenderErrorHandlerFnType; + renderMode?: RenderMode; + hasCompatibleActions?: (event: ExpressionRendererEvent) => Promise; } export interface ExpressionRendererEvent { @@ -59,7 +60,11 @@ export class ExpressionRenderHandler { constructor( element: HTMLElement, - { onRenderError, renderMode }: Partial = {} + { + onRenderError, + renderMode, + hasCompatibleActions = async () => false, + }: ExpressionRenderHandlerParams = {} ) { this.element = element; @@ -96,17 +101,18 @@ export class ExpressionRenderHandler { getRenderMode: () => { return renderMode || 'display'; }, + hasCompatibleActions, }; } - render = async (data: any, uiState: any = {}) => { - if (!data || typeof data !== 'object') { + render = async (value: any, uiState: any = {}) => { + if (!value || typeof value !== 'object') { return this.handleRenderError(new Error('invalid data provided to the expression renderer')); } - if (data.type !== 'render' || !data.as) { - if (data.type === 'error') { - return this.handleRenderError(data.error); + if (value.type !== 'render' || !value.as) { + if (value.type === 'error') { + return this.handleRenderError(value.error); } else { return this.handleRenderError( new Error('invalid data provided to the expression renderer') @@ -114,15 +120,15 @@ export class ExpressionRenderHandler { } } - if (!getRenderersRegistry().get(data.as)) { - return this.handleRenderError(new Error(`invalid renderer id '${data.as}'`)); + if (!getRenderersRegistry().get(value.as)) { + return this.handleRenderError(new Error(`invalid renderer id '${value.as}'`)); } try { // Rendering is asynchronous, completed by handlers.done() await getRenderersRegistry() - .get(data.as)! - .render(this.element, data.value, { + .get(value.as)! + .render(this.element, value.value, { ...this.handlers, uiState, } as any); @@ -152,7 +158,7 @@ export class ExpressionRenderHandler { export function render( element: HTMLElement, data: any, - options?: Partial + options?: ExpressionRenderHandlerParams ): ExpressionRenderHandler { const handler = new ExpressionRenderHandler(element, options); handler.render(data); diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts index 5bae985699476..f37107abbb716 100644 --- a/src/plugins/expressions/public/types/index.ts +++ b/src/plugins/expressions/public/types/index.ts @@ -25,6 +25,7 @@ import { SerializableState, RenderMode, } from '../../common'; +import { ExpressionRenderHandlerParams } from '../render'; /** * @deprecated @@ -56,6 +57,7 @@ export interface IExpressionLoaderParams { onRenderError?: RenderErrorHandlerFnType; searchSessionId?: string; renderMode?: RenderMode; + hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions']; } export interface ExpressionRenderError extends Error { diff --git a/src/plugins/expressions/server/server.api.md b/src/plugins/expressions/server/server.api.md index 761ddba8f9270..7c1ab11f75027 100644 --- a/src/plugins/expressions/server/server.api.md +++ b/src/plugins/expressions/server/server.api.md @@ -736,6 +736,8 @@ export interface IInterpreterRenderHandlers { // (undocumented) getRenderMode: () => RenderMode; // (undocumented) + hasCompatibleActions?: (event: any) => Promise; + // (undocumented) onDestroy: (fn: () => void) => void; // (undocumented) reload: () => void; diff --git a/src/plugins/home/public/application/components/feature_directory.js b/src/plugins/home/public/application/components/feature_directory.js deleted file mode 100644 index 36ececcdfd8df..0000000000000 --- a/src/plugins/home/public/application/components/feature_directory.js +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { Synopsis } from './synopsis'; -import { - EuiTabs, - EuiTab, - EuiFlexItem, - EuiFlexGrid, - EuiPage, - EuiPageBody, - EuiTitle, - EuiSpacer, -} from '@elastic/eui'; - -import { FeatureCatalogueCategory } from '../../services'; - -import { i18n } from '@kbn/i18n'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { createAppNavigationHandler } from './app_navigation_handler'; - -const ALL_TAB_ID = 'all'; -const OTHERS_TAB_ID = 'others'; - -const isOtherCategory = (directory) => { - return ( - directory.category !== FeatureCatalogueCategory.DATA && - directory.category !== FeatureCatalogueCategory.ADMIN - ); -}; - -export class FeatureDirectory extends React.Component { - constructor(props) { - super(props); - - this.tabs = [ - { - id: ALL_TAB_ID, - name: i18n.translate('home.directory.tabs.allTitle', { defaultMessage: 'All' }), - }, - { - id: FeatureCatalogueCategory.DATA, - name: i18n.translate('home.directory.tabs.dataTitle', { - defaultMessage: 'Data Exploration & Visualization', - }), - }, - { - id: FeatureCatalogueCategory.ADMIN, - name: i18n.translate('home.directory.tabs.administrativeTitle', { - defaultMessage: 'Administrative', - }), - }, - ]; - if (props.directories.some(isOtherCategory)) { - this.tabs.push({ - id: OTHERS_TAB_ID, - name: i18n.translate('home.directory.tabs.otherTitle', { defaultMessage: 'Other' }), - }); - } - - this.state = { - selectedTabId: ALL_TAB_ID, - }; - } - - onSelectedTabChanged = (id) => { - this.setState({ - selectedTabId: id, - }); - }; - - renderTabs = () => { - return this.tabs.map((tab, index) => ( - this.onSelectedTabChanged(tab.id)} - isSelected={tab.id === this.state.selectedTabId} - key={index} - > - {tab.name} - - )); - }; - - renderDirectories = () => { - return this.props.directories - .filter((directory) => { - if (this.state.selectedTabId === ALL_TAB_ID) { - return true; - } - if (this.state.selectedTabId === OTHERS_TAB_ID) { - return isOtherCategory(directory); - } - return this.state.selectedTabId === directory.category; - }) - .map((directory) => { - return ( - - - - ); - }); - }; - - render() { - return ( - - - -

- -

-
- - {this.renderTabs()} - - {this.renderDirectories()} -
-
- ); - } -} - -FeatureDirectory.propTypes = { - addBasePath: PropTypes.func.isRequired, - directories: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - description: PropTypes.string.isRequired, - icon: PropTypes.string.isRequired, - path: PropTypes.string.isRequired, - showOnHomePage: PropTypes.bool.isRequired, - category: PropTypes.string.isRequired, - order: PropTypes.number, - }) - ), -}; diff --git a/src/plugins/home/public/application/components/home_app.js b/src/plugins/home/public/application/components/home_app.js index 734100fe584ab..2ea96ad904b21 100644 --- a/src/plugins/home/public/application/components/home_app.js +++ b/src/plugins/home/public/application/components/home_app.js @@ -21,7 +21,6 @@ import React from 'react'; import { I18nProvider } from '@kbn/i18n/react'; import PropTypes from 'prop-types'; import { Home } from './home'; -import { FeatureDirectory } from './feature_directory'; import { TutorialDirectory } from './tutorial_directory'; import { Tutorial } from './tutorial/tutorial'; import { HashRouter as Router, Switch, Route } from 'react-router-dom'; @@ -78,9 +77,6 @@ export function HomeApp({ directories, solutions }) { - - - - -
-
- - - - - + +
diff --git a/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.test.tsx b/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.test.tsx index f90ecdda93242..568677ee389fa 100644 --- a/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.test.tsx +++ b/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.test.tsx @@ -28,7 +28,7 @@ jest.mock('../../app_links', () => ({ jest.mock('../../context', () => ({ useKibana: jest.fn().mockReturnValue({ services: { - application: { capabilities: { advancedSettings: { show: true } } }, + application: { capabilities: { advancedSettings: { show: true, save: true } } }, notifications: { toast: { addSuccess: jest.fn() } }, }, }), diff --git a/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.tsx b/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.tsx index 113992099aee1..576046092d512 100644 --- a/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.tsx +++ b/src/plugins/kibana_react/public/overview_page/overview_page_footer/overview_page_footer.tsx @@ -32,7 +32,7 @@ interface Props { path: string; /** Callback function to invoke when the user wants to set their default route to the current page */ onSetDefaultRoute?: (event: MouseEvent) => void; - /** Callback function to invoke when the user wants to change their default route button is changed */ + /** Callback function to invoke when the user wants to change their default route button is changed */ onChangeDefaultRoute?: (event: MouseEvent) => void; } @@ -51,9 +51,9 @@ export const OverviewPageFooter: FC = ({ } = useKibana(); const { show, save } = application.capabilities.advancedSettings; - const isAdvancedSettingsEnabled = show && save; + if (!show && !save) return <>; - const defaultRoutebutton = defaultRoute.includes(path) ? ( + const defaultRouteButton = defaultRoute.includes(path) ? ( = ({
-
{isAdvancedSettingsEnabled ? defaultRoutebutton : null}
-
- - -
- - - - - -
+
{defaultRouteButton}
diff --git a/src/plugins/ui_actions/public/index.ts b/src/plugins/ui_actions/public/index.ts index b9f4a4a0426bf..d223c0abcccb7 100644 --- a/src/plugins/ui_actions/public/index.ts +++ b/src/plugins/ui_actions/public/index.ts @@ -50,6 +50,9 @@ export { visualizeFieldTrigger, VISUALIZE_GEO_FIELD_TRIGGER, visualizeGeoFieldTrigger, + ROW_CLICK_TRIGGER, + rowClickTrigger, + RowClickContext, } from './triggers'; export { TriggerContextMapping, diff --git a/src/plugins/ui_actions/public/plugin.ts b/src/plugins/ui_actions/public/plugin.ts index 87a1df959ccbd..fdb75e9a426e9 100644 --- a/src/plugins/ui_actions/public/plugin.ts +++ b/src/plugins/ui_actions/public/plugin.ts @@ -23,6 +23,7 @@ import { UiActionsService } from './service'; import { selectRangeTrigger, valueClickTrigger, + rowClickTrigger, applyFilterTrigger, visualizeFieldTrigger, visualizeGeoFieldTrigger, @@ -48,6 +49,7 @@ export class UiActionsPlugin implements Plugin { public setup(core: CoreSetup): UiActionsSetup { this.service.registerTrigger(selectRangeTrigger); this.service.registerTrigger(valueClickTrigger); + this.service.registerTrigger(rowClickTrigger); this.service.registerTrigger(applyFilterTrigger); this.service.registerTrigger(visualizeFieldTrigger); this.service.registerTrigger(visualizeGeoFieldTrigger); diff --git a/src/plugins/ui_actions/public/public.api.md b/src/plugins/ui_actions/public/public.api.md index ca27e19b247c2..2384dfab13c8c 100644 --- a/src/plugins/ui_actions/public/public.api.md +++ b/src/plugins/ui_actions/public/public.api.md @@ -133,6 +133,32 @@ export class IncompatibleActionError extends Error { // @public (undocumented) export function plugin(initializerContext: PluginInitializerContext): UiActionsPlugin; +// Warning: (ae-missing-release-tag) "ROW_CLICK_TRIGGER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const ROW_CLICK_TRIGGER = "ROW_CLICK_TRIGGER"; + +// Warning: (ae-missing-release-tag) "RowClickContext" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export interface RowClickContext { + // (undocumented) + data: { + rowIndex: number; + table: Datatable; + columns?: string[]; + }; + // Warning: (ae-forgotten-export) The symbol "IEmbeddable" needs to be exported by the entry point index.d.ts + // + // (undocumented) + embeddable?: IEmbeddable; +} + +// Warning: (ae-missing-release-tag) "rowClickTrigger" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) +// +// @public (undocumented) +export const rowClickTrigger: Trigger<'ROW_CLICK_TRIGGER'>; + // Warning: (ae-missing-release-tag) "SELECT_RANGE_TRIGGER" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -170,6 +196,8 @@ export interface TriggerContextMapping { // // (undocumented) [APPLY_FILTER_TRIGGER]: ApplyGlobalFilterActionContext; + // (undocumented) + [ROW_CLICK_TRIGGER]: RowClickContext; // Warning: (ae-forgotten-export) The symbol "RangeSelectContext" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -234,14 +262,14 @@ export class UiActionsService { // // (undocumented) protected readonly actions: ActionRegistry; - readonly addTriggerAction: (triggerId: T, action: UiActionsActionDefinition | Action) => void; + readonly addTriggerAction: (triggerId: T, action: UiActionsActionDefinition | Action) => void; // (undocumented) - readonly attachAction: (triggerId: T, actionId: string) => void; + readonly attachAction: (triggerId: T, actionId: string) => void; readonly clear: () => void; // (undocumented) readonly detachAction: (triggerId: TriggerId, actionId: string) => void; // @deprecated (undocumented) - readonly executeTriggerActions: (triggerId: T, context: TriggerContext) => Promise; + readonly executeTriggerActions: (triggerId: T, context: TriggerContext) => Promise; // Warning: (ae-forgotten-export) The symbol "UiActionsExecutionService" needs to be exported by the entry point index.d.ts // // (undocumented) @@ -252,11 +280,11 @@ export class UiActionsService { // Warning: (ae-forgotten-export) The symbol "TriggerContract" needs to be exported by the entry point index.d.ts // // (undocumented) - readonly getTrigger: (triggerId: T) => TriggerContract; + readonly getTrigger: (triggerId: T) => TriggerContract; // (undocumented) - readonly getTriggerActions: (triggerId: T) => Action[]; + readonly getTriggerActions: (triggerId: T) => Action[]; // (undocumented) - readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; + readonly getTriggerCompatibleActions: (triggerId: T, context: TriggerContextMapping[T]) => Promise[]>; // (undocumented) readonly hasAction: (actionId: string) => boolean; // Warning: (ae-forgotten-export) The symbol "ActionContext" needs to be exported by the entry point index.d.ts @@ -341,6 +369,10 @@ export const visualizeFieldTrigger: Trigger<'VISUALIZE_FIELD_TRIGGER'>; export const visualizeGeoFieldTrigger: Trigger<'VISUALIZE_GEO_FIELD_TRIGGER'>; +// Warnings were encountered during analysis: +// +// src/plugins/ui_actions/public/triggers/row_click_trigger.ts:45:5 - (ae-forgotten-export) The symbol "Datatable" needs to be exported by the entry point index.d.ts + // (No @packageDocumentation comment for this package) ``` diff --git a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts index 4f0ab52501a95..59616dcf3f38d 100644 --- a/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts +++ b/src/plugins/ui_actions/public/service/ui_actions_execution_service.ts @@ -29,6 +29,7 @@ interface ExecuteActionTask { context: BaseContext; trigger: Trigger; defer: Defer; + alwaysShowPopup?: boolean; } export class UiActionsExecutionService { @@ -37,21 +38,25 @@ export class UiActionsExecutionService { constructor() {} - async execute({ - action, - context, - trigger, - }: { - action: Action; - context: BaseContext; - trigger: Trigger; - }): Promise { + async execute( + { + action, + context, + trigger, + }: { + action: Action; + context: BaseContext; + trigger: Trigger; + }, + alwaysShowPopup?: boolean + ): Promise { const shouldBatch = !(await action.shouldAutoExecute?.({ ...context, trigger })) ?? false; const task: ExecuteActionTask = { action, context, trigger, defer: createDefer(), + alwaysShowPopup: !!alwaysShowPopup, }; if (shouldBatch) { @@ -84,11 +89,23 @@ export class UiActionsExecutionService { setTimeout(() => { if (this.pendingTasks.size === 0) { const tasks = uniqBy(this.batchingQueue, (t) => t.action.id); - if (tasks.length === 1) { - this.executeSingleTask(tasks[0]); - } - if (tasks.length > 1) { - this.executeMultipleActions(tasks); + if (tasks.length > 0) { + let alwaysShowPopup = false; + for (const task of tasks) { + if (task.alwaysShowPopup) { + alwaysShowPopup = true; + break; + } + } + if (alwaysShowPopup) { + this.showActionPopupMenu(tasks); + } else { + if (tasks.length === 1) { + this.executeSingleTask(tasks[0]); + } else if (tasks.length > 1) { + this.showActionPopupMenu(tasks); + } + } } this.batchingQueue.splice(0, this.batchingQueue.length); @@ -108,7 +125,7 @@ export class UiActionsExecutionService { } } - private async executeMultipleActions(tasks: ExecuteActionTask[]) { + private async showActionPopupMenu(tasks: ExecuteActionTask[]) { const panels = await buildContextMenuForActions({ actions: tasks.map(({ action, context, trigger }) => ({ action, diff --git a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts index af2510467ba87..51ba165ba730b 100644 --- a/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts +++ b/src/plugins/ui_actions/public/tests/execute_trigger_actions.test.ts @@ -143,7 +143,32 @@ test('shows a context menu when more than one action is mapped to a trigger', as const start = doStart(); const context = {}; - await start.executeTriggerActions('MY-TRIGGER' as TriggerId, context); + await start.getTrigger('MY-TRIGGER' as TriggerId)!.exec(context); + + jest.runAllTimers(); + + await waitFor(() => { + expect(executeFn).toBeCalledTimes(0); + expect(openContextMenu).toHaveBeenCalledTimes(1); + }); +}); + +test('shows a context menu when there is only one action mapped to a trigger and "alwaysShowPopup" is set', async () => { + const { setup, doStart } = uiActions; + const trigger: Trigger = { + id: 'MY-TRIGGER' as TriggerId, + title: 'My trigger', + }; + const action1 = createTestAction('test1', () => true); + + setup.registerTrigger(trigger); + setup.addTriggerAction(trigger.id, action1); + + expect(openContextMenu).toHaveBeenCalledTimes(0); + + const start = doStart(); + const context = {}; + await start.getTrigger('MY-TRIGGER' as TriggerId)!.exec(context, true); jest.runAllTimers(); diff --git a/src/plugins/ui_actions/public/triggers/index.ts b/src/plugins/ui_actions/public/triggers/index.ts index b7039d287c6e2..ecbf4d1f7b988 100644 --- a/src/plugins/ui_actions/public/triggers/index.ts +++ b/src/plugins/ui_actions/public/triggers/index.ts @@ -22,6 +22,7 @@ export * from './trigger_contract'; export * from './trigger_internal'; export * from './select_range_trigger'; export * from './value_click_trigger'; +export * from './row_click_trigger'; export * from './apply_filter_trigger'; export * from './visualize_field_trigger'; export * from './visualize_geo_field_trigger'; diff --git a/src/plugins/ui_actions/public/triggers/row_click_trigger.ts b/src/plugins/ui_actions/public/triggers/row_click_trigger.ts new file mode 100644 index 0000000000000..87bca03f8c3ba --- /dev/null +++ b/src/plugins/ui_actions/public/triggers/row_click_trigger.ts @@ -0,0 +1,53 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@kbn/i18n'; +import { IEmbeddable } from '../../../embeddable/public'; +import { Trigger } from '.'; +import { Datatable } from '../../../expressions'; + +export const ROW_CLICK_TRIGGER = 'ROW_CLICK_TRIGGER'; + +export const rowClickTrigger: Trigger<'ROW_CLICK_TRIGGER'> = { + id: ROW_CLICK_TRIGGER, + title: i18n.translate('uiActions.triggers.rowClickTitle', { + defaultMessage: 'Table row click', + }), + description: i18n.translate('uiActions.triggers.rowClickkDescription', { + defaultMessage: 'A click on a table row', + }), +}; + +export interface RowClickContext { + embeddable?: IEmbeddable; + data: { + /** + * Row index, starting from 0, where user clicked. + */ + rowIndex: number; + + table: Datatable; + + /** + * Sorted list column IDs that were visible to the user. Useful when only + * a subset of datatable columns should be used. + */ + columns?: string[]; + }; +} diff --git a/src/plugins/ui_actions/public/triggers/trigger_contract.ts b/src/plugins/ui_actions/public/triggers/trigger_contract.ts index ba1c5a693f937..04a75cb3a53d0 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_contract.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_contract.ts @@ -49,7 +49,7 @@ export class TriggerContract { /** * Use this method to execute action attached to this trigger. */ - public readonly exec = async (context: TriggerContextMapping[T]) => { - await this.internal.execute(context); + public readonly exec = async (context: TriggerContextMapping[T], alwaysShowPopup?: boolean) => { + await this.internal.execute(context, alwaysShowPopup); }; } diff --git a/src/plugins/ui_actions/public/triggers/trigger_internal.ts b/src/plugins/ui_actions/public/triggers/trigger_internal.ts index c766b5c798ecb..fd43a020504c0 100644 --- a/src/plugins/ui_actions/public/triggers/trigger_internal.ts +++ b/src/plugins/ui_actions/public/triggers/trigger_internal.ts @@ -31,17 +31,20 @@ export class TriggerInternal { constructor(public readonly service: UiActionsService, public readonly trigger: Trigger) {} - public async execute(context: TriggerContextMapping[T]) { + public async execute(context: TriggerContextMapping[T], alwaysShowPopup?: boolean) { const triggerId = this.trigger.id; const actions = await this.service.getTriggerCompatibleActions!(triggerId, context); await Promise.all([ actions.map((action) => - this.service.executionService.execute({ - action, - context, - trigger: this.trigger, - }) + this.service.executionService.execute( + { + action, + context, + trigger: this.trigger, + }, + alwaysShowPopup + ) ), ]); } diff --git a/src/plugins/ui_actions/public/types.ts b/src/plugins/ui_actions/public/types.ts index 0be3c19fc1c4d..0266a755be926 100644 --- a/src/plugins/ui_actions/public/types.ts +++ b/src/plugins/ui_actions/public/types.ts @@ -22,10 +22,12 @@ import { TriggerInternal } from './triggers/trigger_internal'; import { SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, + ROW_CLICK_TRIGGER, APPLY_FILTER_TRIGGER, VISUALIZE_FIELD_TRIGGER, VISUALIZE_GEO_FIELD_TRIGGER, DEFAULT_TRIGGER, + RowClickContext, } from './triggers'; import type { RangeSelectContext, ValueClickContext } from '../../embeddable/public'; import type { ApplyGlobalFilterActionContext } from '../../data/public'; @@ -49,6 +51,7 @@ export interface TriggerContextMapping { [DEFAULT_TRIGGER]: TriggerContext; [SELECT_RANGE_TRIGGER]: RangeSelectContext; [VALUE_CLICK_TRIGGER]: ValueClickContext; + [ROW_CLICK_TRIGGER]: RowClickContext; [APPLY_FILTER_TRIGGER]: ApplyGlobalFilterActionContext; [VISUALIZE_FIELD_TRIGGER]: VisualizeFieldContext; [VISUALIZE_GEO_FIELD_TRIGGER]: VisualizeFieldContext; diff --git a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js index 520ad281576cd..89e7a50ab79b0 100644 --- a/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js +++ b/src/plugins/vis_type_timeseries/public/application/components/vis_editor.js @@ -74,20 +74,26 @@ export class VisEditor extends Component { this.props.eventEmitter.emit('dirtyStateChange', { isDirty: false, }); + + const extractedIndexPatterns = extractIndexPatterns(this.state.model); + if (!isEqual(this.state.extractedIndexPatterns, extractedIndexPatterns)) { + this.abortableFetchFields(extractedIndexPatterns).then((visFields) => { + this.setState({ + visFields, + extractedIndexPatterns, + }); + }); + } }, VIS_STATE_DEBOUNCE_DELAY); - debouncedFetchFields = debounce( - (extractedIndexPatterns) => { - if (this.abortControllerFetchFields) { - this.abortControllerFetchFields.abort(); - } - this.abortControllerFetchFields = new AbortController(); + abortableFetchFields = (extractedIndexPatterns) => { + if (this.abortControllerFetchFields) { + this.abortControllerFetchFields.abort(); + } + this.abortControllerFetchFields = new AbortController(); - return fetchFields(extractedIndexPatterns, this.abortControllerFetchFields.signal); - }, - VIS_STATE_DEBOUNCE_DELAY, - { leading: true } - ); + return fetchFields(extractedIndexPatterns, this.abortControllerFetchFields.signal); + }; handleChange = (partialModel) => { if (isEmpty(partialModel)) { @@ -105,16 +111,6 @@ export class VisEditor extends Component { dirty = false; } - const extractedIndexPatterns = extractIndexPatterns(nextModel); - if (!isEqual(this.state.extractedIndexPatterns, extractedIndexPatterns)) { - this.debouncedFetchFields(extractedIndexPatterns).then((visFields) => - this.setState({ - visFields, - extractedIndexPatterns, - }) - ); - } - this.setState({ dirty, model: nextModel, diff --git a/src/plugins/visualizations/public/embeddable/events.ts b/src/plugins/visualizations/public/embeddable/events.ts index 52cac59fbffaa..41e52c3ac1327 100644 --- a/src/plugins/visualizations/public/embeddable/events.ts +++ b/src/plugins/visualizations/public/embeddable/events.ts @@ -21,16 +21,19 @@ import { APPLY_FILTER_TRIGGER, SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, -} from '../../../../plugins/ui_actions/public'; + ROW_CLICK_TRIGGER, +} from '../../../ui_actions/public'; export interface VisEventToTrigger { ['applyFilter']: typeof APPLY_FILTER_TRIGGER; ['brush']: typeof SELECT_RANGE_TRIGGER; ['filter']: typeof VALUE_CLICK_TRIGGER; + ['tableRowContextMenuClick']: typeof ROW_CLICK_TRIGGER; } export const VIS_EVENT_TO_TRIGGER: VisEventToTrigger = { applyFilter: APPLY_FILTER_TRIGGER, brush: SELECT_RANGE_TRIGGER, filter: VALUE_CLICK_TRIGGER, + tableRowContextMenuClick: ROW_CLICK_TRIGGER, }; diff --git a/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx b/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx index 8520b84cc42ad..dcf885a817e91 100644 --- a/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx +++ b/src/plugins/visualizations/public/wizard/group_selection/group_selection.tsx @@ -204,6 +204,7 @@ const VisGroup = ({ visType, onVisTypeSelected }: VisCardProps) => { target="_blank" color="text" className="visNewVisDialog__groupsCardLink" + external={false} > { }, "invalidateApiKeysTask": Object { "interval": "5m", - "removalDelay": "5m", + "removalDelay": "1h", }, } `); diff --git a/x-pack/plugins/alerts/server/config.ts b/x-pack/plugins/alerts/server/config.ts index 41340c7dfe5fc..e53b99852c354 100644 --- a/x-pack/plugins/alerts/server/config.ts +++ b/x-pack/plugins/alerts/server/config.ts @@ -13,7 +13,7 @@ export const configSchema = schema.object({ }), invalidateApiKeysTask: schema.object({ interval: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), - removalDelay: schema.string({ validate: validateDurationSchema, defaultValue: '5m' }), + removalDelay: schema.string({ validate: validateDurationSchema, defaultValue: '1h' }), }), }); diff --git a/x-pack/plugins/alerts/server/plugin.test.ts b/x-pack/plugins/alerts/server/plugin.test.ts index fee7901c4ea55..48fd2e12336a8 100644 --- a/x-pack/plugins/alerts/server/plugin.test.ts +++ b/x-pack/plugins/alerts/server/plugin.test.ts @@ -24,7 +24,7 @@ describe('Alerting Plugin', () => { }, invalidateApiKeysTask: { interval: '5m', - removalDelay: '5m', + removalDelay: '1h', }, }); const plugin = new AlertingPlugin(context); @@ -73,7 +73,7 @@ describe('Alerting Plugin', () => { }, invalidateApiKeysTask: { interval: '5m', - removalDelay: '5m', + removalDelay: '1h', }, }); const plugin = new AlertingPlugin(context); @@ -124,7 +124,7 @@ describe('Alerting Plugin', () => { }, invalidateApiKeysTask: { interval: '5m', - removalDelay: '5m', + removalDelay: '1h', }, }); const plugin = new AlertingPlugin(context); diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts index f243bcc0c694e..90e98e64814a1 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/general_settings.ts @@ -110,7 +110,7 @@ export const generalSettings: RawSettingDefinition[] = [ { text: 'critical', value: 'critical' }, { text: 'off', value: 'off' }, ], - includeAgents: ['dotnet', 'ruby', 'java', 'python', 'nodejs'], + includeAgents: ['dotnet', 'ruby', 'java', 'python', 'nodejs', 'go'], }, // Recording @@ -254,6 +254,6 @@ export const generalSettings: RawSettingDefinition[] = [ 'Used to restrict requests to certain URLs from being instrumented. This config accepts a comma-separated list of wildcard patterns of URL paths that should be ignored. When an incoming HTTP request is detected, its request path will be tested against each element in this list. For example, adding `/home/index` to this list would match and remove instrumentation from `http://localhost/home/index` as well as `http://whatever.com/home/index?value1=123`', } ), - includeAgents: ['java', 'nodejs'], + includeAgents: ['java', 'nodejs', 'python', 'dotnet', 'ruby'], }, ]; diff --git a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts index ac0820309e77c..88cf3e288abf1 100644 --- a/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts +++ b/x-pack/plugins/apm/common/agent_configuration/setting_definitions/index.test.ts @@ -45,6 +45,7 @@ describe('filterByAgent', () => { expect(getSettingKeysForAgent('go')).toEqual([ 'capture_body', 'capture_headers', + 'log_level', 'recording', 'sanitize_field_names', 'span_frames_min_duration', @@ -119,6 +120,7 @@ describe('filterByAgent', () => { 'recording', 'sanitize_field_names', 'span_frames_min_duration', + 'transaction_ignore_urls', 'transaction_max_spans', 'transaction_sample_rate', ]); @@ -133,6 +135,7 @@ describe('filterByAgent', () => { 'sanitize_field_names', 'span_frames_min_duration', 'stack_trace_limit', + 'transaction_ignore_urls', 'transaction_max_spans', 'transaction_sample_rate', ]); @@ -147,6 +150,7 @@ describe('filterByAgent', () => { 'log_level', 'recording', 'span_frames_min_duration', + 'transaction_ignore_urls', 'transaction_max_spans', 'transaction_sample_rate', ]); diff --git a/x-pack/plugins/apm/common/latency_aggregation_types.ts b/x-pack/plugins/apm/common/latency_aggregation_types.ts new file mode 100644 index 0000000000000..6a9e561142429 --- /dev/null +++ b/x-pack/plugins/apm/common/latency_aggregation_types.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 * as t from 'io-ts'; + +export enum LatencyAggregationType { + avg = 'avg', + p99 = 'p99', + p95 = 'p95', +} + +export const latencyAggregationTypeRt = t.union([ + t.literal('avg'), + t.literal('p95'), + t.literal('p99'), +]); diff --git a/x-pack/plugins/apm/dev_docs/updating_functional_tests_archives.md b/x-pack/plugins/apm/dev_docs/updating_functional_tests_archives.md index 467090fb3c91b..88e434d07d38f 100644 --- a/x-pack/plugins/apm/dev_docs/updating_functional_tests_archives.md +++ b/x-pack/plugins/apm/dev_docs/updating_functional_tests_archives.md @@ -1,6 +1,6 @@ ### Updating functional tests archives -Some of our API tests use an archive generated by the [`esarchiver`](https://www.elastic.co/guide/en/kibana/current/development-functional-tests.html) script. Updating the main archive (`apm_8.0.0`) is a scripted process, where a 30m snapshot is downloaded from a cluster running the [APM Integration Testing server](https://github.com/elastic/apm-integration-testing). The script will copy the generated archives into the `fixtures/es_archiver` folders of our test suites (currently `basic` and `trial`). It will also generate a file that contains metadata about the archive, that can be imported to get the time range of the snapshot. +Some of our API tests use an archive generated by the [`esarchiver`](https://www.elastic.co/guide/en/kibana/current/development-tests.html#development-functional-tests) script. Updating the main archive (`apm_8.0.0`) is a scripted process, where a 30m snapshot is downloaded from a cluster running the [APM Integration Testing server](https://github.com/elastic/apm-integration-testing). The script will copy the generated archives into the `fixtures/es_archiver` folders of our test suites (currently `basic` and `trial`). It will also generate a file that contains metadata about the archive, that can be imported to get the time range of the snapshot. Usage: `node x-pack/plugins/apm/scripts/create-functional-tests-archive --es-url=https://admin:changeme@localhost:9200 --kibana-url=https://localhost:5601` diff --git a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js index 0ecda7a113de7..152186a8a738a 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js +++ b/x-pack/plugins/apm/e2e/cypress/integration/snapshots.js @@ -1,3 +1,3 @@ module.exports = { - "__version": "5.4.0" + "__version": "6.0.1" } diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts index 342f3e0aa5267..e558d1ef9c0bc 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/breakdown_filter.ts @@ -6,13 +6,13 @@ import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps'; import { DEFAULT_TIMEOUT } from './csm_dashboard'; +import { waitForLoadingToFinish } from './utils'; /** The default time in ms to wait for a Cypress command to complete */ Given(`a user clicks the page load breakdown filter`, () => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiStat__title-isLoading').should('not.exist'); const breakDownBtn = cy.get( '[data-test-subj=pldBreakdownFilter]', DEFAULT_TIMEOUT @@ -27,7 +27,7 @@ When(`the user selected the breakdown`, () => { }); Then(`breakdown series should appear in chart`, () => { - cy.get('.euiLoadingChart').should('not.be.visible'); + cy.get('.euiLoadingChart').should('not.exist'); cy.get('[data-cy=pageLoadDist]').within(() => { cy.get('div.echLegendItem__label[title=Chrome] ', DEFAULT_TIMEOUT) diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/client_metrics_helper.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/client_metrics_helper.ts index 0b26c6de66f4b..d8d8c7c3a62e9 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/client_metrics_helper.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/client_metrics_helper.ts @@ -5,6 +5,7 @@ */ import { DEFAULT_TIMEOUT } from './csm_dashboard'; +import { waitForLoadingToFinish } from './utils'; /** * Verifies the behavior of the client metrics component @@ -17,15 +18,14 @@ export function verifyClientMetrics( ) { const clientMetricsSelector = '[data-cy=client-metrics] .euiStat__title'; - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); if (checkTitleStatus) { cy.get('.euiStat__title', DEFAULT_TIMEOUT).should('be.visible'); - cy.get('.euiSelect-isLoading').should('not.be.visible'); + cy.get('.euiSelect-isLoading').should('not.exist'); } - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + cy.get('.euiStat__title-isLoading').should('not.exist'); cy.get(clientMetricsSelector).eq(0).should('have.text', metrics[0]); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts index 452d8b719b3cb..5207ea39c959f 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_dashboard.ts @@ -7,6 +7,7 @@ import { Given, Then } from 'cypress-cucumber-preprocessor/steps'; import { loginAndWaitForPage } from '../../../integration/helpers'; import { verifyClientMetrics } from './client_metrics_helper'; +import { waitForLoadingToFinish } from './utils'; /** The default time in ms to wait for a Cypress command to complete */ export const DEFAULT_TIMEOUT = { timeout: 60 * 1000 }; @@ -36,9 +37,9 @@ Then(`should display percentile for page load chart`, () => { cy.get('.euiLoadingChart', DEFAULT_TIMEOUT).should('be.visible'); - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + waitForLoadingToFinish(); + + cy.get('.euiStat__title-isLoading').should('not.exist'); cy.get(pMarkers).eq(0).should('have.text', '50th'); @@ -52,21 +53,19 @@ Then(`should display percentile for page load chart`, () => { Then(`should display chart legend`, () => { const chartLegend = 'div.echLegendItem__label'; - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiLoadingChart').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiLoadingChart').should('not.exist'); cy.get(chartLegend, DEFAULT_TIMEOUT).eq(0).should('have.text', 'Overall'); }); Then(`should display tooltip on hover`, () => { - cy.get('.euiLoadingChart').should('not.be.visible'); + cy.get('.euiLoadingChart').should('not.exist'); const pMarkers = '[data-cy=percentile-markers] span.euiToolTipAnchor'; - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiLoadingChart').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiLoadingChart').should('not.exist'); const marker = cy.get(pMarkers, DEFAULT_TIMEOUT).eq(0); marker.invoke('show'); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_filters.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_filters.ts index 88287286c66c5..9aeddad686385 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_filters.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/csm_filters.ts @@ -7,11 +7,11 @@ import { When, Then } from 'cypress-cucumber-preprocessor/steps'; import { DEFAULT_TIMEOUT } from './csm_dashboard'; import { verifyClientMetrics } from './client_metrics_helper'; +import { waitForLoadingToFinish } from './utils'; When(/^the user filters by "([^"]*)"$/, (filterName) => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiStat__title-isLoading').should('not.exist'); cy.get(`#local-filter-${filterName}`).click(); cy.get(`#local-filter-popover-${filterName}`, DEFAULT_TIMEOUT).within(() => { @@ -51,9 +51,8 @@ When(/^the user filters by "([^"]*)"$/, (filterName) => { }); Then(/^it filters the client metrics "([^"]*)"$/, (filterName) => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiStat__title-isLoading').should('not.exist'); const data = filterName === 'os' diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/js_errors.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/js_errors.ts index 9e10e2fd59914..bc53de0bac6a7 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/js_errors.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/js_errors.ts @@ -9,8 +9,8 @@ import { DEFAULT_TIMEOUT } from './csm_dashboard'; import { getDataTestSubj } from './utils'; Then(`it displays list of relevant js errors`, () => { - cy.get('.euiBasicTable-loading').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + cy.get('.euiBasicTable-loading').should('not.exist'); + cy.get('.euiStat__title-isLoading').should('not.exist'); getDataTestSubj('uxJsErrorsTotal').should('have.text', 'Total errors112'); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts index 44802bbce6208..80b90422366d5 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/percentile_select.ts @@ -6,11 +6,10 @@ import { When, Then } from 'cypress-cucumber-preprocessor/steps'; import { verifyClientMetrics } from './client_metrics_helper'; -import { getDataTestSubj } from './utils'; +import { getDataTestSubj, waitForLoadingToFinish } from './utils'; When('the user changes the selected percentile', () => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); getDataTestSubj('uxPercentileSelect').select('95'); }); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/service_name_filter.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/service_name_filter.ts index 609d0d18f5bc8..5c0e8c6238238 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/service_name_filter.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/service_name_filter.ts @@ -7,10 +7,10 @@ import { When, Then } from 'cypress-cucumber-preprocessor/steps'; import { verifyClientMetrics } from './client_metrics_helper'; import { DEFAULT_TIMEOUT } from './csm_dashboard'; +import { waitForLoadingToFinish } from './utils'; When('the user changes the selected service name', () => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); cy.get(`[data-cy=serviceNameFilter]`, DEFAULT_TIMEOUT).select('client'); }); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts index 3dc98625baf85..cc9dc177d57a0 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/url_search_filter.ts @@ -6,18 +6,18 @@ import { When, Then } from 'cypress-cucumber-preprocessor/steps'; import { DEFAULT_TIMEOUT } from './csm_dashboard'; +import { waitForLoadingToFinish } from './utils'; When(`a user clicks inside url search field`, () => { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - cy.get('.euiStat__title-isLoading').should('not.be.visible'); + waitForLoadingToFinish(); + cy.get('.euiStat__title-isLoading').should('not.exist'); cy.get('span[data-cy=csmUrlFilter]', DEFAULT_TIMEOUT).within(() => { cy.get('input.euiFieldSearch').click(); }); }); Then(`it displays top pages in the suggestion popover`, () => { - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); cy.get('div.euiPopover__panel-isOpen', DEFAULT_TIMEOUT).within(() => { const listOfUrls = cy.get('li.euiSelectableListItem'); @@ -38,17 +38,17 @@ Then(`it displays top pages in the suggestion popover`, () => { }); When(`a user enters a query in url search field`, () => { - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); cy.get('[data-cy=csmUrlFilter]').within(() => { cy.get('input.euiSelectableSearch').type('cus'); }); - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); }); Then(`it should filter results based on query`, () => { - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); cy.get('div.euiPopover__panel-isOpen', DEFAULT_TIMEOUT).within(() => { const listOfUrls = cy.get('li.euiSelectableListItem'); diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/utils.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/utils.ts index 87b3a1d70d073..0819a27ff16cb 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/utils.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/csm/utils.ts @@ -6,9 +6,12 @@ import { DEFAULT_TIMEOUT } from './csm_dashboard'; +export function waitForLoadingToFinish() { + cy.get('[data-test-subj=globalLoadingIndicator-hidden]', DEFAULT_TIMEOUT); +} + export function getDataTestSubj(dataTestSubj: string) { - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); + waitForLoadingToFinish(); return cy.get(`[data-test-subj=${dataTestSubj}]`, DEFAULT_TIMEOUT); } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx index 309cde4dd9f65..8ab09eccd9bdb 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx @@ -7,10 +7,9 @@ import { Axis, Chart, - ElementClickListener, - GeometryValue, HistogramBarSeries, Position, + ProjectionClickListener, RectAnnotation, ScaleType, Settings, @@ -24,11 +23,11 @@ import d3 from 'd3'; import { isEmpty } from 'lodash'; import React from 'react'; import { ValuesType } from 'utility-types'; -import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { useTheme } from '../../../../../../observability/public'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; import type { IUrlParams } from '../../../../context/url_params_context/types'; import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; +import { APIReturnType } from '../../../../services/rest/createCallApmApi'; import { unit } from '../../../../style/variables'; import { ChartContainer } from '../../../shared/charts/chart_container'; import { EmptyMessage } from '../../../shared/EmptyMessage'; @@ -145,10 +144,9 @@ export function TransactionDistribution({ }, }; - const onBarClick: ElementClickListener = (elements) => { - const chartPoint = elements[0][0] as GeometryValue; + const onBarClick: ProjectionClickListener = ({ x }) => { const clickedBucket = distribution?.buckets.find((bucket) => { - return bucket.key === chartPoint.x; + return bucket.key === x; }); if (clickedBucket) { onBucketClick(clickedBucket); @@ -194,10 +192,11 @@ export function TransactionDistribution({ {selectedBucket && ( diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 1f6a9276b5d27..1a86e7baac83f 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -15,12 +15,14 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; +import { AnnotationsContextProvider } from '../../../context/annotations/annotations_context'; import { ChartPointerEventContextProvider } from '../../../context/chart_pointer_event/chart_pointer_event_context'; +import { LatencyChart } from '../../shared/charts/latency_chart'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; import { SearchBar } from '../../shared/search_bar'; -import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; +import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; import { ServiceOverviewThroughputChart } from './service_overview_throughput_chart'; import { ServiceOverviewTransactionsTable } from './service_overview_transactions_table'; @@ -43,99 +45,96 @@ export function ServiceOverview({ useTrackPageview({ app: 'apm', path: 'service_overview', delay: 15000 }); return ( - - - - - - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.latencyChartTitle', - { - defaultMessage: 'Latency', - } - )} -

-
-
-
- - - - - - - - - - - - - - - {!isRumAgentName(agentName) && ( + + + + + + + + + + + + + + + + + + + + + + + + + {!isRumAgentName(agentName) && ( + + + + )} + + + + + + + + + - + + + + + + + + + + + + + + +

+ {i18n.translate( + 'xpack.apm.serviceOverview.instancesLatencyDistributionChartTitle', + { + defaultMessage: 'Instances latency distribution', + } + )} +

+
+
+
+ + + +

+ {i18n.translate( + 'xpack.apm.serviceOverview.instancesTableTitle', + { + defaultMessage: 'Instances', + } + )} +

+
+
- )} - - - - - -
-
- - - - - - - - - - - - - - - - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.instancesLatencyDistributionChartTitle', - { - defaultMessage: 'Instances latency distribution', - } - )} -

-
-
-
- - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.instancesTableTitle', - { - defaultMessage: 'Instances', - } - )} -

-
-
-
-
-
-
-
-
+
+
+
+
+
+ ); } diff --git a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx index e4260a2533d36..886c95cde7248 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/service_overview_transactions_table/index.tsx @@ -15,6 +15,8 @@ import React, { useState } from 'react'; import styled from 'styled-components'; import { EuiToolTip } from '@elastic/eui'; import { ValuesType } from 'utility-types'; +import { useLatencyAggregationType } from '../../../../hooks/use_latency_Aggregation_type'; +import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; import { asDuration, asPercent, @@ -64,9 +66,39 @@ const StyledTransactionDetailLink = styled(TransactionDetailLink)` ${truncate('100%')} `; -export function ServiceOverviewTransactionsTable(props: Props) { - const { serviceName } = props; +function getLatencyAggregationTypeLabel( + latencyAggregationType?: LatencyAggregationType +) { + switch (latencyAggregationType) { + case 'p95': { + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p95', + { + defaultMessage: 'Latency (95th)', + } + ); + } + case 'p99': { + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.p99', + { + defaultMessage: 'Latency (99th)', + } + ); + } + default: { + return i18n.translate( + 'xpack.apm.serviceOverview.transactionsTableColumnLatency.avg', + { + defaultMessage: 'Latency (avg.)', + } + ); + } + } +} +export function ServiceOverviewTransactionsTable({ serviceName }: Props) { + const latencyAggregationType = useLatencyAggregationType(); const { uiFilters, urlParams: { start, end }, @@ -94,7 +126,7 @@ export function ServiceOverviewTransactionsTable(props: Props) { }, status, } = useFetcher(() => { - if (!start || !end) { + if (!start || !end || !latencyAggregationType) { return; } @@ -112,6 +144,7 @@ export function ServiceOverviewTransactionsTable(props: Props) { pageIndex: tableOptions.pageIndex, sortField: tableOptions.sort.field, sortDirection: tableOptions.sort.direction, + latencyAggregationType, }, }, }).then((response) => { @@ -135,6 +168,7 @@ export function ServiceOverviewTransactionsTable(props: Props) { tableOptions.pageIndex, tableOptions.sort.field, tableOptions.sort.direction, + latencyAggregationType, ]); const { @@ -170,12 +204,7 @@ export function ServiceOverviewTransactionsTable(props: Props) { }, { field: 'latency', - name: i18n.translate( - 'xpack.apm.serviceOverview.transactionsTableColumnLatency', - { - defaultMessage: 'Latency', - } - ), + name: getLatencyAggregationTypeLabel(latencyAggregationType), width: px(unit * 10), render: (_, { latency }) => { return ( diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx index 98046193e3807..111dd5d00a978 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/APMLink.tsx @@ -37,7 +37,7 @@ export const PERSISTENT_APM_PARAMS: Array = [ */ export function useAPMHref( path: string, - persistentFilters: Array = PERSISTENT_APM_PARAMS + persistentFilters: Array = [] ) { const { urlParams } = useUrlParams(); const { basePath } = useApmPluginContext().core.http; diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx index 92ff1b8a68ac0..959f60bfa6439 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/TransactionOverviewLink.tsx @@ -15,6 +15,7 @@ const persistedFilters: Array = [ 'containerId', 'podName', 'serviceVersion', + 'latencyAggregationType', ]; export function useTransactionOverviewHref(serviceName: string) { diff --git a/x-pack/plugins/apm/public/components/shared/Links/apm/service_overview_link.tsx b/x-pack/plugins/apm/public/components/shared/Links/apm/service_overview_link.tsx index 78e409bb4558c..1f74f1f9890cf 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/apm/service_overview_link.tsx +++ b/x-pack/plugins/apm/public/components/shared/Links/apm/service_overview_link.tsx @@ -9,19 +9,34 @@ * you may not use this file except in compliance with the Elastic License. */ import React from 'react'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { pickKeys } from '../../../../../common/utils/pick_keys'; +import { APMQueryParams } from '../url_helpers'; import { APMLink, APMLinkExtendProps, useAPMHref } from './APMLink'; interface ServiceOverviewLinkProps extends APMLinkExtendProps { serviceName: string; } +const persistedFilters: Array = [ + 'latencyAggregationType', +]; + export function useServiceOverviewHref(serviceName: string) { - return useAPMHref(`/services/${serviceName}/overview`); + return useAPMHref(`/services/${serviceName}/overview`, persistedFilters); } export function ServiceOverviewLink({ serviceName, ...rest }: ServiceOverviewLinkProps) { - return ; + const { urlParams } = useUrlParams(); + + return ( + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts index 9da26b3fcefac..aa3881b81cc3f 100644 --- a/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts +++ b/x-pack/plugins/apm/public/components/shared/Links/url_helpers.ts @@ -6,6 +6,7 @@ import { History } from 'history'; import { parse, stringify } from 'query-string'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { url } from '../../../../../../../src/plugins/kibana_utils/public'; import { LocalUIFilterName } from '../../../../common/ui_filter'; @@ -84,6 +85,7 @@ export type APMQueryParams = { refreshInterval?: string | number; searchTerm?: string; percentile?: 50 | 75 | 90 | 95 | 99; + latencyAggregationType?: LatencyAggregationType; } & { [key in LocalUIFilterName]?: string }; // forces every value of T[K] to be type: string diff --git a/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx new file mode 100644 index 0000000000000..be7c6babe8e00 --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/latency_chart/index.tsx @@ -0,0 +1,108 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiSelect, EuiTitle } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { useHistory } from 'react-router-dom'; +import { LatencyAggregationType } from '../../../../../common/latency_aggregation_types'; +import { getDurationFormatter } from '../../../../../common/utils/formatters'; +import { useLicenseContext } from '../../../../context/license/use_license_context'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { useTransactionLatencyChartsFetcher } from '../../../../hooks/use_transaction_latency_chart_fetcher'; +import { TimeseriesChart } from '../../../shared/charts/timeseries_chart'; +import { + getMaxY, + getResponseTimeTickFormatter, +} from '../../../shared/charts/transaction_charts/helper'; +import { MLHeader } from '../../../shared/charts/transaction_charts/ml_header'; +import * as urlHelpers from '../../../shared/Links/url_helpers'; + +interface Props { + height?: number; +} + +const options: Array<{ value: LatencyAggregationType; text: string }> = [ + { value: LatencyAggregationType.avg, text: 'Average' }, + { value: LatencyAggregationType.p95, text: '95th percentile' }, + { value: LatencyAggregationType.p99, text: '99th percentile' }, +]; + +export function LatencyChart({ height }: Props) { + const history = useHistory(); + const { urlParams } = useUrlParams(); + const { latencyAggregationType } = urlParams; + const license = useLicenseContext(); + + const { + latencyChartsData, + latencyChartsStatus, + } = useTransactionLatencyChartsFetcher(); + + const { latencyTimeseries, anomalyTimeseries, mlJobId } = latencyChartsData; + + const latencyMaxY = getMaxY(latencyTimeseries); + const latencyFormatter = getDurationFormatter(latencyMaxY); + + return ( + + + + + + + +

+ {i18n.translate( + 'xpack.apm.serviceOverview.latencyChartTitle', + { + defaultMessage: 'Latency', + } + )} +

+
+
+ + { + urlHelpers.push(history, { + query: { + latencyAggregationType: nextOption.target.value, + }, + }); + }} + /> + +
+
+ + + +
+
+ + + +
+ ); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.test.ts b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.test.ts index 850e5d9a16112..e92ecd2aeefd8 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.test.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.test.ts @@ -10,7 +10,7 @@ import { getMaxY, } from './helper'; -import { TimeSeries } from '../../../../../typings/timeseries'; +import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; import { getDurationFormatter, toMicroseconds, @@ -51,7 +51,7 @@ describe('transaction chart helper', () => { it('returns zero for invalid y coordinate', () => { const timeSeries = ([ { data: [{ x: 1 }, { x: 2 }, { x: 3, y: -1 }] }, - ] as unknown) as TimeSeries[]; + ] as unknown) as Array>; expect(getMaxY(timeSeries)).toEqual(0); }); it('returns the max y coordinate', () => { @@ -63,7 +63,7 @@ describe('transaction chart helper', () => { { x: 3, y: 1 }, ], }, - ] as unknown) as TimeSeries[]; + ] as unknown) as Array>; expect(getMaxY(timeSeries)).toEqual(10); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx index db245792982c3..4d2a60c494178 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/helper.tsx @@ -4,11 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -import { flatten } from 'lodash'; -import { TimeFormatter } from '../../../../../common/utils/formatters'; import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; +import { TimeFormatter } from '../../../../../common/utils/formatters'; +import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { TimeSeries, Coordinate } from '../../../../../typings/timeseries'; export function getResponseTimeTickFormatter(formatter: TimeFormatter) { return (t: number) => { @@ -24,12 +23,11 @@ export function getResponseTimeTooltipFormatter(formatter: TimeFormatter) { }; } -export function getMaxY(timeSeries: TimeSeries[]) { - const coordinates = flatten( - timeSeries.map((serie: TimeSeries) => serie.data as Coordinate[]) - ); - - const numbers: number[] = coordinates.map((c: Coordinate) => (c.y ? c.y : 0)); - - return Math.max(...numbers, 0); +export function getMaxY(timeSeries?: Array>) { + if (timeSeries) { + const coordinates = timeSeries.flatMap((serie) => serie.data); + const numbers = coordinates.map((c) => (c.y ? c.y : 0)); + return Math.max(...numbers, 0); + } + return 0; } diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index f43019a5101d0..0ea0ee3e5a456 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -6,7 +6,6 @@ import { EuiFlexGrid, - EuiFlexGroup, EuiFlexItem, EuiPanel, EuiSpacer, @@ -14,44 +13,28 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; -import { - TRANSACTION_PAGE_LOAD, - TRANSACTION_REQUEST, - TRANSACTION_ROUTE_CHANGE, -} from '../../../../../common/transaction_types'; +import { TRANSACTION_REQUEST } from '../../../../../common/transaction_types'; import { asTransactionRate } from '../../../../../common/utils/formatters'; import { AnnotationsContextProvider } from '../../../../context/annotations/annotations_context'; import { ChartPointerEventContextProvider } from '../../../../context/chart_pointer_event/chart_pointer_event_context'; -import { LicenseContext } from '../../../../context/license/license_context'; import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; -import { useTransactionLatencyChartsFetcher } from '../../../../hooks/use_transaction_latency_chart_fetcher'; import { useTransactionThroughputChartsFetcher } from '../../../../hooks/use_transaction_throughput_chart_fetcher'; +import { LatencyChart } from '../latency_chart'; import { TimeseriesChart } from '../timeseries_chart'; import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; -import { getResponseTimeTickFormatter } from './helper'; -import { MLHeader } from './ml_header'; -import { useFormatter } from './use_formatter'; export function TransactionCharts() { const { urlParams } = useUrlParams(); const { transactionType } = urlParams; - const { - latencyChartsData, - latencyChartsStatus, - } = useTransactionLatencyChartsFetcher(); - const { throughputChartsData, throughputChartsStatus, } = useTransactionThroughputChartsFetcher(); - const { latencyTimeseries, anomalyTimeseries, mlJobId } = latencyChartsData; const { throughputTimeseries } = throughputChartsData; - const { formatter, toggleSerie } = useFormatter(latencyTimeseries); - return ( <> @@ -59,35 +42,7 @@ export function TransactionCharts() { - - - - {responseTimeLabel(transactionType)} - - - - {(license) => ( - - )} - - - { - if (serie) { - toggleSerie(serie); - } - }} - /> + @@ -137,29 +92,3 @@ function tpmLabel(type?: string) { } ); } - -function responseTimeLabel(type?: string) { - switch (type) { - case TRANSACTION_PAGE_LOAD: - return i18n.translate( - 'xpack.apm.metrics.transactionChart.pageLoadTimesLabel', - { - defaultMessage: 'Page load times', - } - ); - case TRANSACTION_ROUTE_CHANGE: - return i18n.translate( - 'xpack.apm.metrics.transactionChart.routeChangeTimesLabel', - { - defaultMessage: 'Route change times', - } - ); - default: - return i18n.translate( - 'xpack.apm.metrics.transactionChart.transactionDurationLabel', - { - defaultMessage: 'Transaction duration', - } - ); - } -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.test.tsx deleted file mode 100644 index 958a5db6b66c9..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.test.tsx +++ /dev/null @@ -1,83 +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 { SeriesIdentifier } from '@elastic/charts'; -import { renderHook } from '@testing-library/react-hooks'; -import { act } from 'react-test-renderer'; -import { toMicroseconds } from '../../../../../common/utils/formatters'; -import { TimeSeries } from '../../../../../typings/timeseries'; -import { useFormatter } from './use_formatter'; - -describe('useFormatter', () => { - const timeSeries = ([ - { - title: 'avg', - data: [ - { x: 1, y: toMicroseconds(11, 'minutes') }, - { x: 2, y: toMicroseconds(1, 'minutes') }, - { x: 3, y: toMicroseconds(60, 'seconds') }, - ], - }, - { - title: '95th percentile', - data: [ - { x: 1, y: toMicroseconds(120, 'seconds') }, - { x: 2, y: toMicroseconds(1, 'minutes') }, - { x: 3, y: toMicroseconds(60, 'seconds') }, - ], - }, - { - title: '99th percentile', - data: [ - { x: 1, y: toMicroseconds(60, 'seconds') }, - { x: 2, y: toMicroseconds(5, 'minutes') }, - { x: 3, y: toMicroseconds(100, 'seconds') }, - ], - }, - ] as unknown) as TimeSeries[]; - - it('returns new formatter when disabled series state changes', () => { - const { result } = renderHook(() => useFormatter(timeSeries)); - expect( - result.current.formatter(toMicroseconds(120, 'seconds')).formatted - ).toEqual('2.0 min'); - - act(() => { - result.current.toggleSerie({ - specId: 'avg', - } as SeriesIdentifier); - }); - - expect( - result.current.formatter(toMicroseconds(120, 'seconds')).formatted - ).toEqual('120 s'); - }); - - it('falls back to the first formatter when disabled series is empty', () => { - const { result } = renderHook(() => useFormatter(timeSeries)); - expect( - result.current.formatter(toMicroseconds(120, 'seconds')).formatted - ).toEqual('2.0 min'); - - act(() => { - result.current.toggleSerie({ - specId: 'avg', - } as SeriesIdentifier); - }); - - expect( - result.current.formatter(toMicroseconds(120, 'seconds')).formatted - ).toEqual('120 s'); - - act(() => { - result.current.toggleSerie({ - specId: 'avg', - } as SeriesIdentifier); - }); - expect( - result.current.formatter(toMicroseconds(120, 'seconds')).formatted - ).toEqual('2.0 min'); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.ts b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.ts deleted file mode 100644 index 1475ec2934e95..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/use_formatter.ts +++ /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 { SeriesIdentifier } from '@elastic/charts'; -import { omit } from 'lodash'; -import { useState } from 'react'; -import { - getDurationFormatter, - TimeFormatter, -} from '../../../../../common/utils/formatters'; -import { TimeSeries } from '../../../../../typings/timeseries'; -import { getMaxY } from './helper'; - -export const useFormatter = ( - series?: TimeSeries[] -): { - formatter: TimeFormatter; - toggleSerie: (disabledSerie: SeriesIdentifier) => void; -} => { - const [disabledSeries, setDisabledSeries] = useState< - Record - >({}); - - const visibleSeries = series?.filter( - (serie) => disabledSeries[serie.title] === undefined - ); - - const maxY = getMaxY(visibleSeries || series || []); - const formatter = getDurationFormatter(maxY); - - const toggleSerie = ({ specId }: SeriesIdentifier) => { - if (disabledSeries[specId] !== undefined) { - setDisabledSeries((prevState) => { - return omit(prevState, specId); - }); - } else { - setDisabledSeries((prevState) => { - return { ...prevState, [specId]: 0 }; - }); - } - }; - - return { - formatter, - toggleSerie, - }; -}; diff --git a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts index ccc106cc00c9e..ee0ea7f601f62 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts @@ -5,19 +5,19 @@ */ import { Location } from 'history'; -import { IUrlParams } from './types'; +import { pickKeys } from '../../../common/utils/pick_keys'; +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; +import { toQuery } from '../../components/shared/Links/url_helpers'; import { - removeUndefinedProps, - getStart, getEnd, + getStart, + removeUndefinedProps, toBoolean, toNumber, toString, } from './helpers'; -import { toQuery } from '../../components/shared/Links/url_helpers'; -// eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { localUIFilterNames } from '../../../server/lib/ui_filters/local_ui_filters/config'; -import { pickKeys } from '../../../common/utils/pick_keys'; +import { IUrlParams } from './types'; type TimeUrlParams = Pick< IUrlParams, @@ -48,6 +48,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { environment, searchTerm, percentile, + latencyAggregationType, } = query; const localUIFilters = pickKeys(query, ...localUIFilterNames); @@ -77,6 +78,7 @@ export function resolveUrlParams(location: Location, state: TimeUrlParams) { transactionType, searchTerm: toString(searchTerm), percentile: toNumber(percentile), + latencyAggregationType, // ui filters environment, diff --git a/x-pack/plugins/apm/public/context/url_params_context/types.ts b/x-pack/plugins/apm/public/context/url_params_context/types.ts index 68ef73e7b7bc6..d792c93b7d0dc 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/types.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/types.ts @@ -28,4 +28,5 @@ export type IUrlParams = { pageSize?: number; searchTerm?: string; percentile?: number; + latencyAggregationType?: string; } & Partial>; diff --git a/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.test.ts b/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.test.ts new file mode 100644 index 0000000000000..901877ca67460 --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.test.ts @@ -0,0 +1,46 @@ +/* + * 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 { LatencyAggregationType } from '../../common/latency_aggregation_types'; +import { UIFilters } from '../../typings/ui_filters'; +import { IUrlParams } from '../context/url_params_context/types'; +import * as urlParams from '../context/url_params_context/use_url_params'; +import { useLatencyAggregationType } from './use_latency_Aggregation_type'; + +describe('useLatencyAggregationType', () => { + afterAll(() => { + jest.clearAllMocks(); + }); + it('returns avg when no value was given', () => { + jest.spyOn(urlParams, 'useUrlParams').mockReturnValue({ + urlParams: { latencyAggregationType: undefined } as IUrlParams, + refreshTimeRange: jest.fn(), + uiFilters: {} as UIFilters, + }); + const latencyAggregationType = useLatencyAggregationType(); + expect(latencyAggregationType).toEqual(LatencyAggregationType.avg); + }); + + it('returns avg when no value does not match any of the availabe options', () => { + jest.spyOn(urlParams, 'useUrlParams').mockReturnValue({ + urlParams: { latencyAggregationType: 'invalid_type' } as IUrlParams, + refreshTimeRange: jest.fn(), + uiFilters: {} as UIFilters, + }); + const latencyAggregationType = useLatencyAggregationType(); + expect(latencyAggregationType).toEqual(LatencyAggregationType.avg); + }); + + it('returns the value in the url', () => { + jest.spyOn(urlParams, 'useUrlParams').mockReturnValue({ + urlParams: { latencyAggregationType: 'p95' } as IUrlParams, + refreshTimeRange: jest.fn(), + uiFilters: {} as UIFilters, + }); + const latencyAggregationType = useLatencyAggregationType(); + expect(latencyAggregationType).toEqual(LatencyAggregationType.p95); + }); +}); diff --git a/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.ts b/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.ts new file mode 100644 index 0000000000000..72d07c9e4c22c --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_latency_Aggregation_type.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { LatencyAggregationType } from '../../common/latency_aggregation_types'; +import { useUrlParams } from '../context/url_params_context/use_url_params'; + +export function useLatencyAggregationType(): LatencyAggregationType { + const { + urlParams: { latencyAggregationType }, + } = useUrlParams(); + + if (!latencyAggregationType) { + return LatencyAggregationType.avg; + } + + if (latencyAggregationType in LatencyAggregationType) { + return latencyAggregationType as LatencyAggregationType; + } + + return LatencyAggregationType.avg; +} diff --git a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts index 2434ec9c977ed..7b1e7b06ac283 100644 --- a/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts +++ b/x-pack/plugins/apm/public/hooks/use_transaction_latency_chart_fetcher.ts @@ -8,20 +8,30 @@ import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; import { useFetcher } from './use_fetcher'; import { useUrlParams } from '../context/url_params_context/use_url_params'; +import { useApmServiceContext } from '../context/apm_service/use_apm_service_context'; import { getLatencyChartSelector } from '../selectors/latency_chart_selectors'; import { useTheme } from './use_theme'; +import { useLatencyAggregationType } from './use_latency_Aggregation_type'; export function useTransactionLatencyChartsFetcher() { const { serviceName } = useParams<{ serviceName?: string }>(); + const { transactionType } = useApmServiceContext(); + const latencyAggregationType = useLatencyAggregationType(); const theme = useTheme(); const { - urlParams: { transactionType, start, end, transactionName }, + urlParams: { start, end, transactionName }, uiFilters, } = useUrlParams(); const { data, error, status } = useFetcher( (callApmApi) => { - if (serviceName && start && end) { + if ( + serviceName && + start && + end && + transactionType && + latencyAggregationType + ) { return callApmApi({ endpoint: 'GET /api/apm/services/{serviceName}/transactions/charts/latency', @@ -33,17 +43,33 @@ export function useTransactionLatencyChartsFetcher() { transactionType, transactionName, uiFilters: JSON.stringify(uiFilters), + latencyAggregationType, }, }, }); } }, - [serviceName, start, end, transactionName, transactionType, uiFilters] + [ + serviceName, + start, + end, + transactionName, + transactionType, + uiFilters, + latencyAggregationType, + ] ); const memoizedData = useMemo( - () => getLatencyChartSelector({ latencyChart: data, theme }), - [data, theme] + () => + getLatencyChartSelector({ + latencyChart: data, + theme, + latencyAggregationType, + }), + // It should only update when the data has changed + // eslint-disable-next-line react-hooks/exhaustive-deps + [data] ); return { diff --git a/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts b/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts index 4684742bf4d8b..40157aff3c129 100644 --- a/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts +++ b/x-pack/plugins/apm/public/selectors/latency_chart_selector.test.ts @@ -5,6 +5,7 @@ */ import { EuiTheme } from '../../../xpack_legacy/common'; +import { LatencyAggregationType } from '../../common/latency_aggregation_types'; import { getLatencyChartSelector, LatencyChartsResponse, @@ -21,11 +22,7 @@ const theme = { const latencyChartData = { overallAvgDuration: 1, - latencyTimeseries: { - avg: [{ x: 1, y: 10 }], - p95: [{ x: 2, y: 5 }], - p99: [{ x: 3, y: 8 }], - }, + latencyTimeseries: [{ x: 1, y: 10 }], anomalyTimeseries: { jobId: '1', anomalyBoundaries: [{ x: 1, y: 2 }], @@ -43,32 +40,60 @@ describe('getLatencyChartSelector', () => { anomalyTimeseries: undefined, }); }); - it('returns latency time series', () => { + + it('returns average timeseries', () => { const { anomalyTimeseries, ...latencyWithouAnomaly } = latencyChartData; const latencyTimeseries = getLatencyChartSelector({ latencyChart: latencyWithouAnomaly as LatencyChartsResponse, theme, + latencyAggregationType: LatencyAggregationType.avg, }); expect(latencyTimeseries).toEqual({ latencyTimeseries: [ { - title: 'Avg.', + title: 'Average', data: [{ x: 1, y: 10 }], legendValue: '1 μs', type: 'linemark', color: 'blue', }, + ], + }); + }); + + it('returns 95th percentile timeseries', () => { + const { anomalyTimeseries, ...latencyWithouAnomaly } = latencyChartData; + const latencyTimeseries = getLatencyChartSelector({ + latencyChart: latencyWithouAnomaly as LatencyChartsResponse, + theme, + latencyAggregationType: LatencyAggregationType.p95, + }); + expect(latencyTimeseries).toEqual({ + latencyTimeseries: [ { title: '95th percentile', + data: [{ x: 1, y: 10 }], titleShort: '95th', - data: [{ x: 2, y: 5 }], type: 'linemark', color: 'red', }, + ], + }); + }); + + it('returns 99th percentile timeseries', () => { + const { anomalyTimeseries, ...latencyWithouAnomaly } = latencyChartData; + const latencyTimeseries = getLatencyChartSelector({ + latencyChart: latencyWithouAnomaly as LatencyChartsResponse, + theme, + latencyAggregationType: LatencyAggregationType.p99, + }); + expect(latencyTimeseries).toEqual({ + latencyTimeseries: [ { title: '99th percentile', + data: [{ x: 1, y: 10 }], titleShort: '99th', - data: [{ x: 3, y: 8 }], type: 'linemark', color: 'black', }, @@ -82,27 +107,14 @@ describe('getLatencyChartSelector', () => { const latencyTimeseries = getLatencyChartSelector({ latencyChart: latencyChartData, theme, + latencyAggregationType: LatencyAggregationType.p99, }); expect(latencyTimeseries).toEqual({ latencyTimeseries: [ - { - title: 'Avg.', - data: [{ x: 1, y: 10 }], - legendValue: '1 μs', - type: 'linemark', - color: 'blue', - }, - { - title: '95th percentile', - titleShort: '95th', - data: [{ x: 2, y: 5 }], - type: 'linemark', - color: 'red', - }, { title: '99th percentile', titleShort: '99th', - data: [{ x: 3, y: 8 }], + data: [{ x: 1, y: 10 }], type: 'linemark', color: 'black', }, diff --git a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts index 73b855e12d96e..dee92bbffd27a 100644 --- a/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts +++ b/x-pack/plugins/apm/public/selectors/latency_chart_selectors.ts @@ -6,6 +6,7 @@ import { i18n } from '@kbn/i18n'; import { rgba } from 'polished'; import { EuiTheme } from '../../../observability/public'; +import { LatencyAggregationType } from '../../common/latency_aggregation_types'; import { asDuration } from '../../common/utils/formatters'; import { Coordinate, @@ -17,7 +18,7 @@ import { APIReturnType } from '../services/rest/createCallApmApi'; export type LatencyChartsResponse = APIReturnType<'GET /api/apm/services/{serviceName}/transactions/charts/latency'>; interface LatencyChart { - latencyTimeseries: TimeSeries[]; + latencyTimeseries: Array>; mlJobId?: string; anomalyTimeseries?: { bounderies: TimeSeries; @@ -28,11 +29,13 @@ interface LatencyChart { export function getLatencyChartSelector({ latencyChart, theme, + latencyAggregationType, }: { latencyChart?: LatencyChartsResponse; theme: EuiTheme; + latencyAggregationType?: LatencyAggregationType; }): LatencyChart { - if (!latencyChart) { + if (!latencyChart?.latencyTimeseries || !latencyAggregationType) { return { latencyTimeseries: [], mlJobId: undefined, @@ -40,7 +43,11 @@ export function getLatencyChartSelector({ }; } return { - latencyTimeseries: getLatencyTimeseries({ latencyChart, theme }), + latencyTimeseries: getLatencyTimeseries({ + latencyChart, + theme, + latencyAggregationType, + }), mlJobId: latencyChart.anomalyTimeseries?.jobId, anomalyTimeseries: getAnnomalyTimeseries({ anomalyTimeseries: latencyChart.anomalyTimeseries, @@ -52,53 +59,60 @@ export function getLatencyChartSelector({ function getLatencyTimeseries({ latencyChart, theme, + latencyAggregationType, }: { latencyChart: LatencyChartsResponse; theme: EuiTheme; + latencyAggregationType: LatencyAggregationType; }) { const { overallAvgDuration } = latencyChart; - const { avg, p95, p99 } = latencyChart.latencyTimeseries; + const { latencyTimeseries } = latencyChart; - const series = [ - { - title: i18n.translate( - 'xpack.apm.transactions.latency.chart.averageLabel', + switch (latencyAggregationType) { + case 'avg': { + return [ { - defaultMessage: 'Avg.', - } - ), - data: avg, - legendValue: asDuration(overallAvgDuration), - type: 'linemark', - color: theme.eui.euiColorVis1, - }, - { - title: i18n.translate( - 'xpack.apm.transactions.latency.chart.95thPercentileLabel', + title: i18n.translate( + 'xpack.apm.transactions.latency.chart.averageLabel', + { defaultMessage: 'Average' } + ), + data: latencyTimeseries, + legendValue: asDuration(overallAvgDuration), + type: 'linemark', + color: theme.eui.euiColorVis1, + }, + ]; + } + case 'p95': { + return [ { - defaultMessage: '95th percentile', - } - ), - titleShort: '95th', - data: p95, - type: 'linemark', - color: theme.eui.euiColorVis5, - }, - { - title: i18n.translate( - 'xpack.apm.transactions.latency.chart.99thPercentileLabel', + title: i18n.translate( + 'xpack.apm.transactions.latency.chart.95thPercentileLabel', + { defaultMessage: '95th percentile' } + ), + titleShort: '95th', + data: latencyTimeseries, + type: 'linemark', + color: theme.eui.euiColorVis5, + }, + ]; + } + case 'p99': { + return [ { - defaultMessage: '99th percentile', - } - ), - titleShort: '99th', - data: p99, - type: 'linemark', - color: theme.eui.euiColorVis7, - }, - ]; - - return series; + title: i18n.translate( + 'xpack.apm.transactions.latency.chart.99thPercentileLabel', + { defaultMessage: '99th percentile' } + ), + titleShort: '99th', + data: latencyTimeseries, + type: 'linemark', + color: theme.eui.euiColorVis7, + }, + ]; + } + } + return []; } function getAnnomalyTimeseries({ diff --git a/x-pack/plugins/apm/server/lib/helpers/latency_aggregation_type/index.ts b/x-pack/plugins/apm/server/lib/helpers/latency_aggregation_type/index.ts new file mode 100644 index 0000000000000..bc7390a8dccfa --- /dev/null +++ b/x-pack/plugins/apm/server/lib/helpers/latency_aggregation_type/index.ts @@ -0,0 +1,51 @@ +/* + * 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 { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; + +export function getLatencyAggregation( + latencyAggregationType: LatencyAggregationType, + field: string +) { + return { + latency: { + ...(latencyAggregationType === LatencyAggregationType.avg + ? { avg: { field } } + : { + percentiles: { + field, + percents: [ + latencyAggregationType === LatencyAggregationType.p95 ? 95 : 99, + ], + }, + }), + }, + }; +} + +export function getLatencyValue({ + latencyAggregationType, + aggregation, +}: { + latencyAggregationType: LatencyAggregationType; + aggregation: + | { value: number | null } + | { values: Record }; +}) { + if ('value' in aggregation) { + return aggregation.value; + } + if ('values' in aggregation) { + if (latencyAggregationType === LatencyAggregationType.p95) { + return aggregation.values['95.0']; + } + + if (latencyAggregationType === LatencyAggregationType.p99) { + return aggregation.values['99.0']; + } + } + + return null; +} diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts index 73b91429f5101..dd199b6b2c43d 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_timeseries_data_for_transaction_groups.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { PromiseReturnType } from '../../../../../observability/typings/common'; import { EventOutcome } from '../../../../common/event_outcome'; import { rangeFilter } from '../../../../common/utils/range_filter'; @@ -21,6 +22,7 @@ import { } from '../../helpers/aggregated_transactions'; import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; import { getBucketSize } from '../../helpers/get_bucket_size'; +import { getLatencyAggregation } from '../../helpers/latency_aggregation_type'; export type TransactionGroupTimeseriesData = PromiseReturnType< typeof getTimeseriesDataForTransactionGroups @@ -36,6 +38,7 @@ export async function getTimeseriesDataForTransactionGroups({ searchAggregatedTransactions, size, numBuckets, + latencyAggregationType, }: { apmEventClient: APMEventClient; start: number; @@ -46,9 +49,14 @@ export async function getTimeseriesDataForTransactionGroups({ searchAggregatedTransactions: boolean; size: number; numBuckets: number; + latencyAggregationType: LatencyAggregationType; }) { const { intervalString } = getBucketSize({ start, end, numBuckets }); + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); + const timeseriesResponse = await apmEventClient.search({ apm: { events: [ @@ -92,35 +100,11 @@ export async function getTimeseriesDataForTransactionGroups({ }, }, aggs: { - avg_latency: { - avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - transaction_count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, + ...getLatencyAggregation(latencyAggregationType, field), + transaction_count: { value_count: { field } }, [EVENT_OUTCOME]: { - filter: { - term: { - [EVENT_OUTCOME]: EventOutcome.failure, - }, - }, - aggs: { - transaction_count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, + filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + aggs: { transaction_count: { value_count: { field } } }, }, }, }, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts index 5d3d7014ba8f8..244c840d19026 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/get_transaction_groups_for_page.ts @@ -5,6 +5,7 @@ */ import { orderBy } from 'lodash'; import { ValuesType } from 'utility-types'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { PromiseReturnType } from '../../../../../observability/typings/common'; import { EventOutcome } from '../../../../common/event_outcome'; import { ESFilter } from '../../../../../../typings/elasticsearch'; @@ -19,6 +20,10 @@ import { getTransactionDurationFieldForAggregatedTransactions, } from '../../helpers/aggregated_transactions'; import { APMEventClient } from '../../helpers/create_es_client/create_apm_event_client'; +import { + getLatencyAggregation, + getLatencyValue, +} from '../../helpers/latency_aggregation_type'; export type ServiceOverviewTransactionGroupSortField = | 'latency' @@ -41,6 +46,7 @@ export async function getTransactionGroupsForPage({ sortDirection, pageIndex, size, + latencyAggregationType, }: { apmEventClient: APMEventClient; searchAggregatedTransactions: boolean; @@ -52,7 +58,12 @@ export async function getTransactionGroupsForPage({ sortDirection: 'asc' | 'desc'; pageIndex: number; size: number; + latencyAggregationType: LatencyAggregationType; }) { + const field = getTransactionDurationFieldForAggregatedTransactions( + searchAggregatedTransactions + ); + const response = await apmEventClient.search({ apm: { events: [ @@ -77,40 +88,14 @@ export async function getTransactionGroupsForPage({ terms: { field: TRANSACTION_NAME, size: 500, - order: { - _count: 'desc', - }, + order: { _count: 'desc' }, }, aggs: { - avg_latency: { - avg: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - transaction_count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, + ...getLatencyAggregation(latencyAggregationType, field), + transaction_count: { value_count: { field } }, [EVENT_OUTCOME]: { - filter: { - term: { - [EVENT_OUTCOME]: EventOutcome.failure, - }, - }, - aggs: { - transaction_count: { - value_count: { - field: getTransactionDurationFieldForAggregatedTransactions( - searchAggregatedTransactions - ), - }, - }, - }, + filter: { term: { [EVENT_OUTCOME]: EventOutcome.failure } }, + aggs: { transaction_count: { value_count: { field } } }, }, }, }, @@ -128,7 +113,10 @@ export async function getTransactionGroupsForPage({ return { name: bucket.key as string, - latency: bucket.avg_latency.value, + latency: getLatencyValue({ + latencyAggregationType, + aggregation: bucket.latency, + }), throughput: bucket.transaction_count.value, errorRate, }; diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts index 88fd189712e07..a619e51b8e89e 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/index.ts @@ -4,6 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { Setup, SetupTimeRange } from '../../helpers/setup_request'; import { getTimeseriesDataForTransactionGroups } from './get_timeseries_data_for_transaction_groups'; import { @@ -21,6 +22,7 @@ export async function getServiceTransactionGroups({ sortDirection, sortField, searchAggregatedTransactions, + latencyAggregationType, }: { serviceName: string; setup: Setup & SetupTimeRange; @@ -30,6 +32,7 @@ export async function getServiceTransactionGroups({ sortDirection: 'asc' | 'desc'; sortField: ServiceOverviewTransactionGroupSortField; searchAggregatedTransactions: boolean; + latencyAggregationType: LatencyAggregationType; }) { const { apmEventClient, start, end, esFilter } = setup; @@ -48,6 +51,7 @@ export async function getServiceTransactionGroups({ sortDirection, size, searchAggregatedTransactions, + latencyAggregationType, }); const transactionNames = transactionGroups.map((group) => group.name); @@ -62,6 +66,7 @@ export async function getServiceTransactionGroups({ serviceName, size, transactionNames, + latencyAggregationType, }); return { @@ -70,6 +75,7 @@ export async function getServiceTransactionGroups({ timeseriesData, start, end, + latencyAggregationType, }), totalTransactionGroups, isAggregationAccurate, diff --git a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts index 5f53bfa18c468..6d9fc2fd3d579 100644 --- a/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts +++ b/x-pack/plugins/apm/server/lib/services/get_service_transaction_groups/merge_transaction_group_data.ts @@ -4,12 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { EVENT_OUTCOME } from '../../../../common/elasticsearch_fieldnames'; import { TRANSACTION_PAGE_LOAD, TRANSACTION_REQUEST, } from '../../../../common/transaction_types'; +import { getLatencyValue } from '../../helpers/latency_aggregation_type'; import { TransactionGroupTimeseriesData } from './get_timeseries_data_for_transaction_groups'; @@ -20,11 +22,13 @@ export function mergeTransactionGroupData({ end, transactionGroups, timeseriesData, + latencyAggregationType, }: { start: number; end: number; transactionGroups: TransactionGroupWithoutTimeseriesData[]; timeseriesData: TransactionGroupTimeseriesData; + latencyAggregationType: LatencyAggregationType; }) { const deltaAsMinutes = (end - start) / 1000 / 60; @@ -53,7 +57,10 @@ export function mergeTransactionGroupData({ ...acc.latency, timeseries: acc.latency.timeseries.concat({ x: point.key, - y: point.avg_latency.value, + y: getLatencyValue({ + latencyAggregationType, + aggregation: point.latency, + }), }), }, throughput: { diff --git a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts index 27dd7c0f6970b..a4b9bf8dfc6a8 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_anomaly_data/index.ts @@ -32,7 +32,7 @@ export async function getAnomalySeries({ setup: Setup & SetupTimeRange; logger: Logger; }) { - const timeseriesDates = latencyTimeseries?.avg?.map(({ x }) => x); + const timeseriesDates = latencyTimeseries?.map(({ x }) => x); /* * don't fetch: diff --git a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts index 35e14fcc4624b..72464d0f8c2fa 100644 --- a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts +++ b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/index.ts @@ -10,6 +10,7 @@ import { TRANSACTION_NAME, TRANSACTION_TYPE, } from '../../../../common/elasticsearch_fieldnames'; +import { LatencyAggregationType } from '../../../../common/latency_aggregation_types'; import { rangeFilter } from '../../../../common/utils/range_filter'; import { getDocumentTypeFilterForAggregatedTransactions, @@ -18,8 +19,10 @@ import { } from '../../../lib/helpers/aggregated_transactions'; import { getBucketSize } from '../../../lib/helpers/get_bucket_size'; import { Setup, SetupTimeRange } from '../../../lib/helpers/setup_request'; -import { convertLatencyBucketsToCoordinates } from './transform'; - +import { + getLatencyAggregation, + getLatencyValue, +} from '../../helpers/latency_aggregation_type'; export type LatencyChartsSearchResponse = PromiseReturnType< typeof searchLatency >; @@ -30,12 +33,14 @@ async function searchLatency({ transactionName, setup, searchAggregatedTransactions, + latencyAggregationType, }: { serviceName: string; transactionType: string | undefined; transactionName: string | undefined; setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; + latencyAggregationType: LatencyAggregationType; }) { const { start, end, apmEventClient } = setup; const { intervalString } = getBucketSize({ start, end }); @@ -57,7 +62,7 @@ async function searchLatency({ filter.push({ term: { [TRANSACTION_TYPE]: transactionType } }); } - const field = getTransactionDurationFieldForAggregatedTransactions( + const transactionDurationField = getTransactionDurationFieldForAggregatedTransactions( searchAggregatedTransactions ); @@ -80,18 +85,12 @@ async function searchLatency({ min_doc_count: 0, extended_bounds: { min: start, max: end }, }, - aggs: { - avg: { avg: { field } }, - pct: { - percentiles: { - field, - percents: [95, 99], - hdr: { number_of_significant_value_digits: 2 }, - }, - }, - }, + aggs: getLatencyAggregation( + latencyAggregationType, + transactionDurationField + ), }, - overall_avg_duration: { avg: { field } }, + overall_avg_duration: { avg: { field: transactionDurationField } }, }, }, }; @@ -105,12 +104,14 @@ export async function getLatencyTimeseries({ transactionName, setup, searchAggregatedTransactions, + latencyAggregationType, }: { serviceName: string; transactionType: string | undefined; transactionName: string | undefined; setup: Setup & SetupTimeRange; searchAggregatedTransactions: boolean; + latencyAggregationType: LatencyAggregationType; }) { const response = await searchLatency({ serviceName, @@ -118,20 +119,26 @@ export async function getLatencyTimeseries({ transactionName, setup, searchAggregatedTransactions, + latencyAggregationType, }); if (!response.aggregations) { - return { - latencyTimeseries: { avg: [], p95: [], p99: [] }, - overallAvgDuration: null, - }; + return { latencyTimeseries: [], overallAvgDuration: null }; } return { overallAvgDuration: response.aggregations.overall_avg_duration.value || null, - latencyTimeseries: convertLatencyBucketsToCoordinates( - response.aggregations.latencyTimeseries.buckets + latencyTimeseries: response.aggregations.latencyTimeseries.buckets.map( + (bucket) => { + return { + x: bucket.key, + y: getLatencyValue({ + latencyAggregationType, + aggregation: bucket.latency, + }), + }; + } ), }; } diff --git a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/transform.ts b/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/transform.ts deleted file mode 100644 index f4d914afc9483..0000000000000 --- a/x-pack/plugins/apm/server/lib/transactions/get_latency_charts/transform.ts +++ /dev/null @@ -1,31 +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 { isNumber } from 'lodash'; -import { LatencyChartsSearchResponse } from '.'; -import { Coordinate } from '../../../../typings/timeseries'; - -type LatencyBuckets = Required['aggregations']['latencyTimeseries']['buckets']; - -export function convertLatencyBucketsToCoordinates( - latencyBuckets: LatencyBuckets = [] -) { - return latencyBuckets.reduce( - (acc, bucket) => { - const { '95.0': p95, '99.0': p99 } = bucket.pct.values; - - acc.avg.push({ x: bucket.key, y: bucket.avg.value }); - acc.p95.push({ x: bucket.key, y: isNumber(p95) ? p95 : null }); - acc.p99.push({ x: bucket.key, y: isNumber(p99) ? p99 : null }); - return acc; - }, - { - avg: [] as Coordinate[], - p95: [] as Coordinate[], - p99: [] as Coordinate[], - } - ); -} diff --git a/x-pack/plugins/apm/server/routes/transactions.ts b/x-pack/plugins/apm/server/routes/transactions.ts index 9b7a0981a4fed..8621de72adcbb 100644 --- a/x-pack/plugins/apm/server/routes/transactions.ts +++ b/x-pack/plugins/apm/server/routes/transactions.ts @@ -19,6 +19,10 @@ import { getTransactionGroupList } from '../lib/transaction_groups'; import { getErrorRate } from '../lib/transaction_groups/get_error_rate'; import { getLatencyTimeseries } from '../lib/transactions/get_latency_charts'; import { getThroughputCharts } from '../lib/transactions/get_throughput_charts'; +import { + LatencyAggregationType, + latencyAggregationTypeRt, +} from '../../common/latency_aggregation_types'; /** * Returns a list of transactions grouped by name @@ -78,6 +82,7 @@ export const transactionGroupsOverviewRoute = createRoute({ t.literal('errorRate'), t.literal('impact'), ]), + latencyAggregationType: latencyAggregationTypeRt, }), ]), }), @@ -93,7 +98,14 @@ export const transactionGroupsOverviewRoute = createRoute({ const { path: { serviceName }, - query: { size, numBuckets, pageIndex, sortDirection, sortField }, + query: { + size, + numBuckets, + pageIndex, + sortDirection, + sortField, + latencyAggregationType, + }, } = context.params; return getServiceTransactionGroups({ @@ -105,6 +117,7 @@ export const transactionGroupsOverviewRoute = createRoute({ sortDirection, sortField, numBuckets, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, }); }, }); @@ -117,9 +130,12 @@ export const transactionLatencyChatsRoute = createRoute({ }), query: t.intersection([ t.partial({ - transactionType: t.string, transactionName: t.string, }), + t.type({ + transactionType: t.string, + latencyAggregationType: latencyAggregationTypeRt, + }), uiFiltersRt, rangeRt, ]), @@ -129,7 +145,11 @@ export const transactionLatencyChatsRoute = createRoute({ const setup = await setupRequest(context, request); const logger = context.logger; const { serviceName } = context.params.path; - const { transactionType, transactionName } = context.params.query; + const { + transactionType, + transactionName, + latencyAggregationType, + } = context.params.query; if (!setup.uiFilters.environment) { throw Boom.badRequest( @@ -152,7 +172,10 @@ export const transactionLatencyChatsRoute = createRoute({ const { latencyTimeseries, overallAvgDuration, - } = await getLatencyTimeseries(options); + } = await getLatencyTimeseries({ + ...options, + latencyAggregationType: latencyAggregationType as LatencyAggregationType, + }); const anomalyTimeseries = await getAnomalySeries({ ...options, diff --git a/x-pack/plugins/case/common/api/cases/case.ts b/x-pack/plugins/case/common/api/cases/case.ts index 9b99bf0e54cc2..a08e1fbca66ea 100644 --- a/x-pack/plugins/case/common/api/cases/case.ts +++ b/x-pack/plugins/case/common/api/cases/case.ts @@ -29,12 +29,17 @@ const CaseStatusRt = rt.union([ export const caseStatuses = Object.values(CaseStatuses); +const SettingsRt = rt.type({ + syncAlerts: rt.boolean, +}); + const CaseBasicRt = rt.type({ - connector: CaseConnectorRt, description: rt.string, status: CaseStatusRt, tags: rt.array(rt.string), title: rt.string, + connector: CaseConnectorRt, + settings: SettingsRt, }); const CaseExternalServiceBasicRt = rt.type({ @@ -74,6 +79,7 @@ export const CasePostRequestRt = rt.type({ tags: rt.array(rt.string), title: rt.string, connector: CaseConnectorRt, + settings: SettingsRt, }); export const CaseExternalServiceRequestRt = CaseExternalServiceBasicRt; diff --git a/x-pack/plugins/case/common/api/cases/user_actions.ts b/x-pack/plugins/case/common/api/cases/user_actions.ts index 1a3ccfc04eed9..e7aa67db9287e 100644 --- a/x-pack/plugins/case/common/api/cases/user_actions.ts +++ b/x-pack/plugins/case/common/api/cases/user_actions.ts @@ -20,6 +20,7 @@ const UserActionFieldRt = rt.array( rt.literal('tags'), rt.literal('title'), rt.literal('status'), + rt.literal('settings'), ]) ); const UserActionRt = rt.union([ diff --git a/x-pack/plugins/case/kibana.json b/x-pack/plugins/case/kibana.json index 55416ee28c7df..2048ae41fa8ab 100644 --- a/x-pack/plugins/case/kibana.json +++ b/x-pack/plugins/case/kibana.json @@ -2,7 +2,7 @@ "configPath": ["xpack", "case"], "id": "case", "kibanaVersion": "kibana", - "requiredPlugins": ["actions"], + "requiredPlugins": ["actions", "securitySolution"], "optionalPlugins": [ "spaces", "security" diff --git a/x-pack/plugins/case/server/client/alerts/update_status.ts b/x-pack/plugins/case/server/client/alerts/update_status.ts new file mode 100644 index 0000000000000..d90424eb5fb15 --- /dev/null +++ b/x-pack/plugins/case/server/client/alerts/update_status.ts @@ -0,0 +1,25 @@ +/* + * 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 Boom from '@hapi/boom'; +import { CaseClientUpdateAlertsStatus, CaseClientFactoryArguments } from '../types'; + +export const updateAlertsStatus = ({ + alertsService, + request, + context, +}: CaseClientFactoryArguments) => async ({ + ids, + status, +}: CaseClientUpdateAlertsStatus): Promise => { + const securitySolutionClient = context?.securitySolution?.getAppClient(); + if (securitySolutionClient == null) { + throw Boom.notFound('securitySolutionClient client have not been found'); + } + + const index = securitySolutionClient.getSignalsIndex(); + await alertsService.updateAlertsStatus({ ids, status, index, request }); +}; diff --git a/x-pack/plugins/case/server/client/cases/create.test.ts b/x-pack/plugins/case/server/client/cases/create.test.ts index e09ce226b3125..90116e3728883 100644 --- a/x-pack/plugins/case/server/client/cases/create.test.ts +++ b/x-pack/plugins/case/server/client/cases/create.test.ts @@ -34,6 +34,9 @@ describe('create', () => { type: ConnectorTypes.jira, fields: { issueType: 'Task', priority: 'High', parent: null }, }, + settings: { + syncAlerts: true, + }, } as CasePostRequest; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -65,6 +68,9 @@ describe('create', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); expect( @@ -79,9 +85,9 @@ describe('create', () => { full_name: 'Awesome D00d', username: 'awesome', }, - action_field: ['description', 'status', 'tags', 'title', 'connector'], + action_field: ['description', 'status', 'tags', 'title', 'connector', 'settings'], new_value: - '{"description":"This is a brand new case of a bad meanie defacing data","title":"Super Bad Security Issue","tags":["defacement"],"connector":{"id":"123","name":"Jira","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}}}', + '{"description":"This is a brand new case of a bad meanie defacing data","title":"Super Bad Security Issue","tags":["defacement"],"connector":{"id":"123","name":"Jira","type":".jira","fields":{"issueType":"Task","priority":"High","parent":null}},"settings":{"syncAlerts":true}}', old_value: null, }, references: [ @@ -106,6 +112,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -131,6 +140,9 @@ describe('create', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); }); @@ -145,6 +157,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -174,6 +189,9 @@ describe('create', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); }); }); @@ -323,6 +341,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ @@ -347,6 +368,9 @@ describe('create', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; const savedObjectsClient = createMockSavedObjectsRepository({ caseSavedObject: mockCases, diff --git a/x-pack/plugins/case/server/client/cases/create.ts b/x-pack/plugins/case/server/client/cases/create.ts index 59222be062c75..1dca025036c1e 100644 --- a/x-pack/plugins/case/server/client/cases/create.ts +++ b/x-pack/plugins/case/server/client/cases/create.ts @@ -64,7 +64,7 @@ export const create = ({ actionAt: createdDate, actionBy: { username, full_name, email }, caseId: newCase.id, - fields: ['description', 'status', 'tags', 'title', 'connector'], + fields: ['description', 'status', 'tags', 'title', 'connector', 'settings'], newValue: JSON.stringify(query), }), ], diff --git a/x-pack/plugins/case/server/client/cases/update.test.ts b/x-pack/plugins/case/server/client/cases/update.test.ts index ae701f16b2bcb..1f9e8cc788404 100644 --- a/x-pack/plugins/case/server/client/cases/update.test.ts +++ b/x-pack/plugins/case/server/client/cases/update.test.ts @@ -38,7 +38,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -63,6 +66,9 @@ describe('update', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); @@ -115,7 +121,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -140,6 +149,9 @@ describe('update', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -160,7 +172,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -185,6 +200,9 @@ describe('update', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -210,7 +228,10 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - const res = await caseClient.client.update({ cases: patchCases }); + const res = await caseClient.client.update({ + caseClient: caseClient.client, + cases: patchCases, + }); expect(res).toEqual([ { @@ -243,6 +264,9 @@ describe('update', () => { username: 'awesome', }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -328,7 +352,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.update({ cases: patchCases }).catch((e) => { + caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(406); @@ -358,7 +382,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.update({ cases: patchCases }).catch((e) => { + caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(404); @@ -385,7 +409,7 @@ describe('update', () => { }); const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); - caseClient.client.update({ cases: patchCases }).catch((e) => { + caseClient.client.update({ caseClient: caseClient.client, cases: patchCases }).catch((e) => { expect(e).not.toBeNull(); expect(e.isBoom).toBe(true); expect(e.output.statusCode).toBe(409); diff --git a/x-pack/plugins/case/server/client/cases/update.ts b/x-pack/plugins/case/server/client/cases/update.ts index 406e43a74cccf..e2b6cb8337251 100644 --- a/x-pack/plugins/case/server/client/cases/update.ts +++ b/x-pack/plugins/case/server/client/cases/update.ts @@ -9,6 +9,7 @@ import { pipe } from 'fp-ts/lib/pipeable'; import { fold } from 'fp-ts/lib/Either'; import { identity } from 'fp-ts/lib/function'; +import { SavedObjectsFindResponse } from 'kibana/server'; import { flattenCaseSavedObject } from '../../routes/api/utils'; import { @@ -34,7 +35,10 @@ export const update = ({ caseService, userActionService, request, -}: CaseClientFactoryArguments) => async ({ cases }: CaseClientUpdate): Promise => { +}: CaseClientFactoryArguments) => async ({ + caseClient, + cases, +}: CaseClientUpdate): Promise => { const query = pipe( excess(CasesPatchRequestRt).decode(cases), fold(throwErrors(Boom.badRequest), identity) @@ -126,6 +130,65 @@ export const update = ({ }), }); + // If a status update occurred and the case is synced then we need to update all alerts' status + // attached to the case to the new status. + const casesWithStatusChangedAndSynced = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.status != null && + currentCase.attributes.status !== caseToUpdate.status && + currentCase.attributes.settings.syncAlerts + ); + }); + + // If syncAlerts setting turned on we need to update all alerts' status + // attached to the case to the current status. + const casesWithSyncSettingChangedToOn = updateFilterCases.filter((caseToUpdate) => { + const currentCase = myCases.saved_objects.find((c) => c.id === caseToUpdate.id); + return ( + currentCase != null && + caseToUpdate.settings?.syncAlerts != null && + currentCase.attributes.settings.syncAlerts !== caseToUpdate.settings.syncAlerts && + caseToUpdate.settings.syncAlerts + ); + }); + + for (const theCase of [ + ...casesWithSyncSettingChangedToOn, + ...casesWithStatusChangedAndSynced, + ]) { + const currentCase = myCases.saved_objects.find((c) => c.id === theCase.id); + const totalComments = await caseService.getAllCaseComments({ + client: savedObjectsClient, + caseId: theCase.id, + options: { + fields: [], + filter: 'cases-comments.attributes.type: alert', + page: 1, + perPage: 1, + }, + }); + + const caseComments = (await caseService.getAllCaseComments({ + client: savedObjectsClient, + caseId: theCase.id, + options: { + fields: [], + filter: 'cases-comments.attributes.type: alert', + page: 1, + perPage: totalComments.total, + }, + // The filter guarantees that the comments will be of type alert + })) as SavedObjectsFindResponse<{ alertId: string }>; + + caseClient.updateAlertsStatus({ + ids: caseComments.saved_objects.map(({ attributes: { alertId } }) => alertId), + // Either there is a status update or the syncAlerts got turned on. + status: theCase.status ?? currentCase?.attributes.status ?? CaseStatuses.open, + }); + } + const returnUpdatedCase = myCases.saved_objects .filter((myCase) => updatedCases.saved_objects.some((updatedCase) => updatedCase.id === myCase.id) diff --git a/x-pack/plugins/case/server/client/comments/add.test.ts b/x-pack/plugins/case/server/client/comments/add.test.ts index d00df5a3246bd..40b87f6ad17f0 100644 --- a/x-pack/plugins/case/server/client/comments/add.test.ts +++ b/x-pack/plugins/case/server/client/comments/add.test.ts @@ -31,6 +31,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -66,6 +67,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { type: CommentType.alert, @@ -103,6 +105,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -126,6 +129,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -173,6 +177,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient, true); const res = await caseClient.client.addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -267,6 +272,7 @@ describe('addComment', () => { ['alertId', 'index'].forEach((attribute) => { caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -328,6 +334,7 @@ describe('addComment', () => { ['comment'].forEach((attribute) => { caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { [attribute]: attribute, @@ -354,6 +361,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'not-exists', comment: { comment: 'Wow, good luck catching that bad meanie!', @@ -377,6 +385,7 @@ describe('addComment', () => { const caseClient = await createCaseClientWithMockSavedObjectsClient(savedObjectsClient); caseClient.client .addComment({ + caseClient: caseClient.client, caseId: 'mock-id-1', comment: { comment: 'Throw an error', diff --git a/x-pack/plugins/case/server/client/comments/add.ts b/x-pack/plugins/case/server/client/comments/add.ts index 169157c95d4c1..bb61094cfa3bd 100644 --- a/x-pack/plugins/case/server/client/comments/add.ts +++ b/x-pack/plugins/case/server/client/comments/add.ts @@ -11,7 +11,14 @@ import { identity } from 'fp-ts/lib/function'; import { decodeComment, flattenCaseSavedObject, transformNewComment } from '../../routes/api/utils'; -import { throwErrors, CaseResponseRt, CommentRequestRt, CaseResponse } from '../../../common/api'; +import { + throwErrors, + CaseResponseRt, + CommentRequestRt, + CaseResponse, + CommentType, + CaseStatuses, +} from '../../../common/api'; import { buildCommentUserActionItem } from '../../services/user_actions/helpers'; import { CaseClientAddComment, CaseClientFactoryArguments } from '../types'; @@ -23,11 +30,11 @@ export const addComment = ({ userActionService, request, }: CaseClientFactoryArguments) => async ({ + caseClient, caseId, comment, }: CaseClientAddComment): Promise => { const query = pipe( - // TODO: Excess CommentRequestRt when the excess() function supports union types CommentRequestRt.decode(comment), fold(throwErrors(Boom.badRequest), identity) ); @@ -39,6 +46,11 @@ export const addComment = ({ caseId, }); + // An alert cannot be attach to a closed case. + if (query.type === CommentType.alert && myCase.attributes.status === CaseStatuses.closed) { + throw Boom.badRequest('Alert cannot be attached to a closed case'); + } + // eslint-disable-next-line @typescript-eslint/naming-convention const { username, full_name, email } = await caseService.getUser({ request }); const createdDate = new Date().toISOString(); @@ -72,6 +84,14 @@ export const addComment = ({ }), ]); + // If the case is synced with alerts the newly attached alert must match the status of the case. + if (newComment.attributes.type === CommentType.alert && myCase.attributes.settings.syncAlerts) { + caseClient.updateAlertsStatus({ + ids: [newComment.attributes.alertId], + status: myCase.attributes.status, + }); + } + const totalCommentsFindByCases = await caseService.getAllCaseComments({ client: savedObjectsClient, caseId, diff --git a/x-pack/plugins/case/server/client/index.test.ts b/x-pack/plugins/case/server/client/index.test.ts index 1ecdc8ea96dea..ef4491204d9f5 100644 --- a/x-pack/plugins/case/server/client/index.test.ts +++ b/x-pack/plugins/case/server/client/index.test.ts @@ -4,32 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; import { savedObjectsClientMock } from '../../../../../src/core/server/mocks'; import { createCaseClient } from '.'; import { createCaseServiceMock, createConfigureServiceMock, createUserActionServiceMock, + createAlertServiceMock, } from '../services/mocks'; import { create } from './cases/create'; import { update } from './cases/update'; import { addComment } from './comments/add'; +import { updateAlertsStatus } from './alerts/update_status'; jest.mock('./cases/create'); jest.mock('./cases/update'); jest.mock('./comments/add'); +jest.mock('./alerts/update_status'); const caseService = createCaseServiceMock(); const caseConfigureService = createConfigureServiceMock(); const userActionService = createUserActionServiceMock(); +const alertsService = createAlertServiceMock(); const savedObjectsClient = savedObjectsClientMock.create(); const request = {} as KibanaRequest; +const context = {} as RequestHandlerContext; const createMock = create as jest.Mock; const updateMock = update as jest.Mock; const addCommentMock = addComment as jest.Mock; +const updateAlertsStatusMock = updateAlertsStatus as jest.Mock; describe('createCaseClient()', () => { test('it creates the client correctly', async () => { @@ -39,6 +45,8 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, }); expect(createMock).toHaveBeenCalledWith({ @@ -47,6 +55,8 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, }); expect(updateMock).toHaveBeenCalledWith({ @@ -55,6 +65,8 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, }); expect(addCommentMock).toHaveBeenCalledWith({ @@ -63,6 +75,18 @@ describe('createCaseClient()', () => { caseConfigureService, caseService, userActionService, + alertsService, + context, + }); + + expect(updateAlertsStatusMock).toHaveBeenCalledWith({ + savedObjectsClient, + request, + caseConfigureService, + caseService, + userActionService, + alertsService, + context, }); }); }); diff --git a/x-pack/plugins/case/server/client/index.ts b/x-pack/plugins/case/server/client/index.ts index 75e9e3c4cfebc..bf43921b46466 100644 --- a/x-pack/plugins/case/server/client/index.ts +++ b/x-pack/plugins/case/server/client/index.ts @@ -8,6 +8,7 @@ import { CaseClientFactoryArguments, CaseClient } from './types'; import { create } from './cases/create'; import { update } from './cases/update'; import { addComment } from './comments/add'; +import { updateAlertsStatus } from './alerts/update_status'; export { CaseClient } from './types'; @@ -17,6 +18,8 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, }: CaseClientFactoryArguments): CaseClient => { return { create: create({ @@ -25,6 +28,8 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, }), update: update({ savedObjectsClient, @@ -32,6 +37,8 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, }), addComment: addComment({ savedObjectsClient, @@ -39,6 +46,17 @@ export const createCaseClient = ({ caseConfigureService, caseService, userActionService, + alertsService, + context, + }), + updateAlertsStatus: updateAlertsStatus({ + savedObjectsClient, + request, + caseConfigureService, + caseService, + userActionService, + alertsService, + context, }), }; }; diff --git a/x-pack/plugins/case/server/client/mocks.ts b/x-pack/plugins/case/server/client/mocks.ts index 243dd884f9ef6..dd4e8b52b4dc6 100644 --- a/x-pack/plugins/case/server/client/mocks.ts +++ b/x-pack/plugins/case/server/client/mocks.ts @@ -4,18 +4,26 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest } from 'kibana/server'; -import { loggingSystemMock } from '../../../../../src/core/server/mocks'; -import { CaseService, CaseConfigureService, CaseUserActionServiceSetup } from '../services'; +import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; +import { loggingSystemMock, elasticsearchServiceMock } from '../../../../../src/core/server/mocks'; +import { actionsClientMock } from '../../../actions/server/mocks'; +import { + CaseService, + CaseConfigureService, + CaseUserActionServiceSetup, + AlertService, +} from '../services'; import { CaseClient } from './types'; import { authenticationMock } from '../routes/api/__fixtures__'; import { createCaseClient } from '.'; +import { getActions } from '../routes/api/__mocks__/request_responses'; export type CaseClientMock = jest.Mocked; export const createCaseClientMock = (): CaseClientMock => ({ create: jest.fn(), update: jest.fn(), addComment: jest.fn(), + updateAlertsStatus: jest.fn(), }); export const createCaseClientWithMockSavedObjectsClient = async ( @@ -25,7 +33,10 @@ export const createCaseClientWithMockSavedObjectsClient = async ( client: CaseClient; services: { userActionService: jest.Mocked }; }> => { + const actionsMock = actionsClientMock.create(); + actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); const log = loggingSystemMock.create().get('case'); + const esClientMock = elasticsearchServiceMock.createClusterClient(); const request = {} as KibanaRequest; const caseServicePlugin = new CaseService(log); @@ -39,15 +50,38 @@ export const createCaseClientWithMockSavedObjectsClient = async ( postUserActions: jest.fn(), getUserActions: jest.fn(), }; + const alertsService = new AlertService(); + alertsService.initialize(esClientMock); + + const context = ({ + core: { + savedObjects: { + client: savedObjectsClient, + }, + }, + actions: { getActionsClient: () => actionsMock }, + case: { + getCaseClient: () => caseClient, + }, + securitySolution: { + getAppClient: () => ({ + getSignalsIndex: () => '.siem-signals', + }), + }, + } as unknown) as RequestHandlerContext; + + const caseClient = createCaseClient({ + savedObjectsClient, + request, + caseService, + caseConfigureService, + userActionService, + alertsService, + context, + }); return { - client: createCaseClient({ - savedObjectsClient, - request, - caseService, - caseConfigureService, - userActionService, - }), + client: caseClient, services: { userActionService }, }; }; diff --git a/x-pack/plugins/case/server/client/types.ts b/x-pack/plugins/case/server/client/types.ts index 8db7d8a5747d7..a9e8494c43dbc 100644 --- a/x-pack/plugins/case/server/client/types.ts +++ b/x-pack/plugins/case/server/client/types.ts @@ -4,18 +4,20 @@ * you may not use this file except in compliance with the Elastic License. */ -import { KibanaRequest, SavedObjectsClientContract } from '../../../../../src/core/server'; +import { KibanaRequest, SavedObjectsClientContract, RequestHandlerContext } from 'kibana/server'; import { CasePostRequest, CasesPatchRequest, CommentRequest, CaseResponse, CasesResponse, + CaseStatuses, } from '../../common/api'; import { CaseConfigureServiceSetup, CaseServiceSetup, CaseUserActionServiceSetup, + AlertServiceContract, } from '../services'; export interface CaseClientCreate { @@ -23,24 +25,36 @@ export interface CaseClientCreate { } export interface CaseClientUpdate { + caseClient: CaseClient; cases: CasesPatchRequest; } export interface CaseClientAddComment { + caseClient: CaseClient; caseId: string; comment: CommentRequest; } +export interface CaseClientUpdateAlertsStatus { + ids: string[]; + status: CaseStatuses; +} + +type PartialExceptFor = Partial & Pick; + export interface CaseClientFactoryArguments { savedObjectsClient: SavedObjectsClientContract; request: KibanaRequest; caseConfigureService: CaseConfigureServiceSetup; caseService: CaseServiceSetup; userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; + context?: PartialExceptFor; } export interface CaseClient { create: (args: CaseClientCreate) => Promise; update: (args: CaseClientUpdate) => Promise; addComment: (args: CaseClientAddComment) => Promise; + updateAlertsStatus: (args: CaseClientUpdateAlertsStatus) => Promise; } diff --git a/x-pack/plugins/case/server/connectors/case/index.test.ts b/x-pack/plugins/case/server/connectors/case/index.test.ts index adf94661216cb..9f5b186c0c687 100644 --- a/x-pack/plugins/case/server/connectors/case/index.test.ts +++ b/x-pack/plugins/case/server/connectors/case/index.test.ts @@ -14,6 +14,7 @@ import { createCaseServiceMock, createConfigureServiceMock, createUserActionServiceMock, + createAlertServiceMock, } from '../../services/mocks'; import { CaseActionType, CaseActionTypeExecutorOptions, CaseExecutorParams } from './types'; import { getActionType } from '.'; @@ -35,11 +36,13 @@ describe('case connector', () => { const caseService = createCaseServiceMock(); const caseConfigureService = createConfigureServiceMock(); const userActionService = createUserActionServiceMock(); + const alertsService = createAlertServiceMock(); caseActionType = getActionType({ logger, caseService, caseConfigureService, userActionService, + alertsService, }); }); @@ -62,6 +65,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -98,6 +104,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -118,6 +127,9 @@ describe('case connector', () => { severityCode: '3', }, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -139,6 +151,9 @@ describe('case connector', () => { urgency: 'Medium', }, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -156,6 +171,9 @@ describe('case connector', () => { type: '.none', fields: null, }, + settings: { + syncAlerts: true, + }, }, }, }, @@ -180,6 +198,9 @@ describe('case connector', () => { type: '.servicenow', fields: {}, }, + settings: { + syncAlerts: true, + }, }, }; @@ -195,6 +216,9 @@ describe('case connector', () => { type: '.servicenow', fields: { impact: null, severity: null, urgency: null }, }, + settings: { + syncAlerts: true, + }, }, }); }); @@ -212,6 +236,9 @@ describe('case connector', () => { type: '.none', fields: null, }, + settings: { + syncAlerts: true, + }, }, }; @@ -234,6 +261,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -262,6 +292,9 @@ describe('case connector', () => { excess: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -289,6 +322,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -312,6 +348,9 @@ describe('case connector', () => { type: '.none', fields: {}, }, + settings: { + syncAlerts: true, + }, }, }; @@ -343,6 +382,7 @@ describe('case connector', () => { title: null, status: null, connector: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -375,6 +415,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -405,6 +446,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -436,6 +478,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -465,6 +508,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, connector: { id: 'servicenow', name: 'Servicenow', @@ -497,6 +541,7 @@ describe('case connector', () => { tags: null, title: null, status: null, + settings: null, ...(params.subActionParams as Record), }, }); @@ -630,7 +675,9 @@ describe('case connector', () => { expect(validateParams(caseActionType, params)).toEqual(params); }); - it('succeeds when type is an alert', () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('succeeds when type is an alert', () => { const params: Record = { subAction: 'addComment', subActionParams: { @@ -656,6 +703,26 @@ describe('case connector', () => { }).toThrow(); }); + // TODO: Remove it when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it('fails when type is an alert', () => { + const params: Record = { + subAction: 'addComment', + subActionParams: { + caseId: 'case-id', + comment: { + type: CommentType.alert, + alertId: 'test-id', + index: 'test-index', + }, + }, + }; + + expect(() => { + validateParams(caseActionType, params); + }).toThrow(); + }); + it('fails when missing attributes: type user', () => { const allParams = { type: CommentType.user, @@ -678,7 +745,9 @@ describe('case connector', () => { }); }); - it('fails when missing attributes: type alert', () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('fails when missing attributes: type alert', () => { const allParams = { type: CommentType.alert, comment: 'a comment', @@ -720,7 +789,9 @@ describe('case connector', () => { }); }); - it('fails when excess attributes are provided: type alert', () => { + // TODO: Enable when the creation of comments of type alert is supported + // https://github.com/elastic/kibana/issues/85750 + it.skip('fails when excess attributes are provided: type alert', () => { ['comment'].forEach((attribute) => { const params: Record = { subAction: 'addComment', @@ -789,6 +860,9 @@ describe('case connector', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }; mockCaseClient.create.mockReturnValue(Promise.resolve(createReturn)); @@ -810,6 +884,9 @@ describe('case connector', () => { parent: null, }, }, + settings: { + syncAlerts: true, + }, }, }; @@ -879,6 +956,9 @@ describe('case connector', () => { username: 'awesome', }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]; @@ -895,6 +975,7 @@ describe('case connector', () => { tags: null, status: null, connector: null, + settings: null, }, }; @@ -910,6 +991,7 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: updateReturn }); expect(mockCaseClient.update).toHaveBeenCalledWith({ + caseClient: mockCaseClient, // Null values have been striped out. cases: { cases: [ @@ -960,6 +1042,9 @@ describe('case connector', () => { version: 'WzksMV0=', }, ], + settings: { + syncAlerts: true, + }, }; mockCaseClient.addComment.mockReturnValue(Promise.resolve(commentReturn)); @@ -988,6 +1073,7 @@ describe('case connector', () => { expect(result).toEqual({ actionId, status: 'ok', data: commentReturn }); expect(mockCaseClient.addComment).toHaveBeenCalledWith({ + caseClient: mockCaseClient, caseId: 'case-id', comment: { comment: 'a comment', diff --git a/x-pack/plugins/case/server/connectors/case/index.ts b/x-pack/plugins/case/server/connectors/case/index.ts index dc647d288ec65..48124b8ae32eb 100644 --- a/x-pack/plugins/case/server/connectors/case/index.ts +++ b/x-pack/plugins/case/server/connectors/case/index.ts @@ -6,7 +6,7 @@ import { curry } from 'lodash'; -import { KibanaRequest } from 'kibana/server'; +import { KibanaRequest, RequestHandlerContext } from 'kibana/server'; import { ActionTypeExecutorResult } from '../../../../actions/common'; import { CasePatchRequest, CasePostRequest } from '../../../common/api'; import { createCaseClient } from '../../client'; @@ -30,6 +30,7 @@ export function getActionType({ caseService, caseConfigureService, userActionService, + alertsService, }: GetActionTypeParams): CaseActionType { return { id: CASE_ACTION_TYPE_ID, @@ -39,13 +40,25 @@ export function getActionType({ config: CaseConfigurationSchema, params: CaseExecutorParamsSchema, }, - executor: curry(executor)({ logger, caseService, caseConfigureService, userActionService }), + executor: curry(executor)({ + logger, + caseService, + caseConfigureService, + userActionService, + alertsService, + }), }; } // action executor async function executor( - { logger, caseService, caseConfigureService, userActionService }: GetActionTypeParams, + { + logger, + caseService, + caseConfigureService, + userActionService, + alertsService, + }: GetActionTypeParams, execOptions: CaseActionTypeExecutorOptions ): Promise> { const { actionId, params, services } = execOptions; @@ -59,6 +72,9 @@ async function executor( caseService, caseConfigureService, userActionService, + alertsService, + // TODO: When case connector is enabled we should figure out how to pass the context. + context: {} as RequestHandlerContext, }); if (!supportedSubActions.includes(subAction)) { @@ -80,12 +96,15 @@ async function executor( {} as CasePatchRequest ); - data = await caseClient.update({ cases: { cases: [updateParamsWithoutNullValues] } }); + data = await caseClient.update({ + caseClient, + cases: { cases: [updateParamsWithoutNullValues] }, + }); } if (subAction === 'addComment') { const { caseId, comment } = subActionParams as ExecutorSubActionAddCommentParams; - data = await caseClient.addComment({ caseId, comment }); + data = await caseClient.addComment({ caseClient, caseId, comment }); } return { status: 'ok', data: data ?? {}, actionId }; diff --git a/x-pack/plugins/case/server/connectors/case/schema.ts b/x-pack/plugins/case/server/connectors/case/schema.ts index 039c0e2e7e67f..d17c9ce6eb1cc 100644 --- a/x-pack/plugins/case/server/connectors/case/schema.ts +++ b/x-pack/plugins/case/server/connectors/case/schema.ts @@ -14,13 +14,27 @@ const ContextTypeUserSchema = schema.object({ comment: schema.string(), }); -const ContextTypeAlertSchema = schema.object({ - type: schema.literal('alert'), - alertId: schema.string(), - index: schema.string(), -}); - -export const CommentSchema = schema.oneOf([ContextTypeUserSchema, ContextTypeAlertSchema]); +/** + * ContextTypeAlertSchema has been deleted. + * Comments of type alert need the siem signal index. + * Case connector is not being passed the context which contains the + * security solution app client which in turn provides the siem signal index. + * For that reason, we disable comments of type alert for the case connector until + * we figure out how to pass the security solution app client to the connector. + * See: x-pack/plugins/case/server/connectors/case/index.ts L76. + * + * The schema: + * + * const ContextTypeAlertSchema = schema.object({ + * type: schema.literal('alert'), + * alertId: schema.string(), + * index: schema.string(), + * }); + * + * Issue: https://github.com/elastic/kibana/issues/85750 + * */ + +export const CommentSchema = schema.oneOf([ContextTypeUserSchema]); const JiraFieldsSchema = schema.object({ issueType: schema.string(), @@ -80,6 +94,7 @@ const CaseBasicProps = { title: schema.string(), tags: schema.arrayOf(schema.string()), connector: schema.object(ConnectorProps, { validate: validateConnector }), + settings: schema.object({ syncAlerts: schema.boolean() }), }; const CaseUpdateRequestProps = { @@ -89,6 +104,7 @@ const CaseUpdateRequestProps = { title: schema.nullable(CaseBasicProps.title), tags: schema.nullable(CaseBasicProps.tags), connector: schema.nullable(CaseBasicProps.connector), + settings: schema.nullable(CaseBasicProps.settings), status: schema.nullable(schema.string()), }; diff --git a/x-pack/plugins/case/server/connectors/index.ts b/x-pack/plugins/case/server/connectors/index.ts index bee7b1e475457..f373445719164 100644 --- a/x-pack/plugins/case/server/connectors/index.ts +++ b/x-pack/plugins/case/server/connectors/index.ts @@ -16,6 +16,7 @@ import { CaseServiceSetup, CaseConfigureServiceSetup, CaseUserActionServiceSetup, + AlertServiceContract, } from '../services'; import { getActionType as getCaseConnector } from './case'; @@ -26,6 +27,7 @@ export interface GetActionTypeParams { caseService: CaseServiceSetup; caseConfigureService: CaseConfigureServiceSetup; userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; } export interface RegisterConnectorsArgs extends GetActionTypeParams { @@ -45,6 +47,7 @@ export const registerConnectors = ({ caseService, caseConfigureService, userActionService, + alertsService, }: RegisterConnectorsArgs) => { actionsRegisterType( getCaseConnector({ @@ -52,6 +55,7 @@ export const registerConnectors = ({ caseService, caseConfigureService, userActionService, + alertsService, }) ); }; diff --git a/x-pack/plugins/case/server/plugin.ts b/x-pack/plugins/case/server/plugin.ts index 64c4b422d1cf7..8d508ce0b76b1 100644 --- a/x-pack/plugins/case/server/plugin.ts +++ b/x-pack/plugins/case/server/plugin.ts @@ -11,6 +11,7 @@ import { Logger, PluginInitializerContext, RequestHandler, + RequestHandlerContext, } from 'kibana/server'; import { CoreSetup, CoreStart } from 'src/core/server'; @@ -33,6 +34,8 @@ import { CaseServiceSetup, CaseUserActionService, CaseUserActionServiceSetup, + AlertService, + AlertServiceContract, } from './services'; import { createCaseClient } from './client'; import { registerConnectors } from './connectors'; @@ -51,6 +54,7 @@ export class CasePlugin { private caseService?: CaseServiceSetup; private caseConfigureService?: CaseConfigureServiceSetup; private userActionService?: CaseUserActionServiceSetup; + private alertsService?: AlertService; constructor(private readonly initializerContext: PluginInitializerContext) { this.log = this.initializerContext.logger.get(); @@ -79,6 +83,7 @@ export class CasePlugin { }); this.caseConfigureService = await new CaseConfigureService(this.log).setup(); this.userActionService = await new CaseUserActionService(this.log).setup(); + this.alertsService = new AlertService(); core.http.registerRouteHandlerContext( APP_ID, @@ -87,6 +92,7 @@ export class CasePlugin { caseService: this.caseService, caseConfigureService: this.caseConfigureService, userActionService: this.userActionService, + alertsService: this.alertsService, }) ); @@ -104,24 +110,31 @@ export class CasePlugin { caseService: this.caseService, caseConfigureService: this.caseConfigureService, userActionService: this.userActionService, + alertsService: this.alertsService, }); } public async start(core: CoreStart) { this.log.debug(`Starting Case Workflow`); + this.alertsService!.initialize(core.elasticsearch.client); - const getCaseClientWithRequest = async (request: KibanaRequest) => { + const getCaseClientWithRequestAndContext = async ( + context: RequestHandlerContext, + request: KibanaRequest + ) => { return createCaseClient({ savedObjectsClient: core.savedObjects.getScopedClient(request), request, caseService: this.caseService!, caseConfigureService: this.caseConfigureService!, userActionService: this.userActionService!, + alertsService: this.alertsService!, + context, }); }; return { - getCaseClientWithRequest, + getCaseClientWithRequestAndContext, }; } @@ -134,11 +147,13 @@ export class CasePlugin { caseService, caseConfigureService, userActionService, + alertsService, }: { core: CoreSetup; caseService: CaseServiceSetup; caseConfigureService: CaseConfigureServiceSetup; userActionService: CaseUserActionServiceSetup; + alertsService: AlertServiceContract; }): IContextProvider, typeof APP_ID> => { return async (context, request) => { const [{ savedObjects }] = await core.getStartServices(); @@ -149,7 +164,9 @@ export class CasePlugin { caseService, caseConfigureService, userActionService, + alertsService, request, + context, }); }, }; diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts index 95856dd75d0ae..645673fdee756 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/mock_saved_objects.ts @@ -44,6 +44,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T21:54:48.952Z', @@ -78,6 +81,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T22:32:00.900Z', @@ -116,6 +122,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -158,6 +167,9 @@ export const mockCases: Array> = [ email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T22:32:17.947Z', @@ -188,6 +200,9 @@ export const mockCaseNoConnectorId: SavedObject> = { email: 'testemail@elastic.co', username: 'elastic', }, + settings: { + syncAlerts: true, + }, }, references: [], updated_at: '2019-11-25T21:54:48.952Z', diff --git a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts index 67890599fa417..dcae1c6083eb6 100644 --- a/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts +++ b/x-pack/plugins/case/server/routes/api/__fixtures__/route_contexts.ts @@ -5,10 +5,10 @@ */ import { RequestHandlerContext, KibanaRequest } from 'src/core/server'; -import { loggingSystemMock } from 'src/core/server/mocks'; +import { loggingSystemMock, elasticsearchServiceMock } from 'src/core/server/mocks'; import { actionsClientMock } from '../../../../../actions/server/mocks'; import { createCaseClient } from '../../../client'; -import { CaseService, CaseConfigureService } from '../../../services'; +import { CaseService, CaseConfigureService, AlertService } from '../../../services'; import { getActions } from '../__mocks__/request_responses'; import { authenticationMock } from '../__fixtures__'; @@ -16,6 +16,7 @@ export const createRouteContext = async (client: any, badAuth = false) => { const actionsMock = actionsClientMock.create(); actionsMock.getAll.mockImplementation(() => Promise.resolve(getActions())); const log = loggingSystemMock.create().get('case'); + const esClientMock = elasticsearchServiceMock.createClusterClient(); const caseServicePlugin = new CaseService(log); const caseConfigureServicePlugin = new CaseConfigureService(log); @@ -24,18 +25,10 @@ export const createRouteContext = async (client: any, badAuth = false) => { authentication: badAuth ? authenticationMock.createInvalid() : authenticationMock.create(), }); const caseConfigureService = await caseConfigureServicePlugin.setup(); - const caseClient = createCaseClient({ - savedObjectsClient: client, - request: {} as KibanaRequest, - caseService, - caseConfigureService, - userActionService: { - postUserActions: jest.fn(), - getUserActions: jest.fn(), - }, - }); + const alertsService = new AlertService(); + alertsService.initialize(esClientMock); - return ({ + const context = ({ core: { savedObjects: { client, @@ -45,5 +38,25 @@ export const createRouteContext = async (client: any, badAuth = false) => { case: { getCaseClient: () => caseClient, }, + securitySolution: { + getAppClient: () => ({ + getSignalsIndex: () => '.siem-signals', + }), + }, } as unknown) as RequestHandlerContext; + + const caseClient = createCaseClient({ + savedObjectsClient: client, + request: {} as KibanaRequest, + caseService, + caseConfigureService, + userActionService: { + postUserActions: jest.fn(), + getUserActions: jest.fn(), + }, + alertsService, + context, + }); + + return context; }; diff --git a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts index ce35b99750419..209fa11116c56 100644 --- a/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts +++ b/x-pack/plugins/case/server/routes/api/__mocks__/request_responses.ts @@ -17,6 +17,9 @@ export const newCase: CasePostRequest = { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; export const getActions = (): FindActionResult[] => [ diff --git a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts index 08d442bccf2cb..139fb7c5f27a4 100644 --- a/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts +++ b/x-pack/plugins/case/server/routes/api/cases/comments/post_comment.ts @@ -32,7 +32,7 @@ export function initPostCommentApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.addComment({ caseId, comment }), + body: await caseClient.addComment({ caseClient, caseId, comment }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts index 053f9ec18ab0f..6a6f5653375b8 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.test.ts @@ -74,6 +74,9 @@ describe('PATCH cases', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -125,6 +128,9 @@ describe('PATCH cases', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -175,6 +181,9 @@ describe('PATCH cases', () => { updated_at: '2019-11-25T21:54:48.952Z', updated_by: { email: 'd00d@awesome.com', full_name: 'Awesome D00d', username: 'awesome' }, version: 'WzE3LDFd', + settings: { + syncAlerts: true, + }, }, ]); }); diff --git a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts index 873671a909801..178e40520d9d2 100644 --- a/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts +++ b/x-pack/plugins/case/server/routes/api/cases/patch_cases.ts @@ -27,7 +27,7 @@ export function initPatchCasesApi({ router }: RouteDeps) { try { return response.ok({ - body: await caseClient.update({ cases }), + body: await caseClient.update({ caseClient, cases }), }); } catch (error) { return response.customError(wrapError(error)); diff --git a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts index 508684b422891..ea59959b0e849 100644 --- a/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts +++ b/x-pack/plugins/case/server/routes/api/cases/post_case.test.ts @@ -42,6 +42,9 @@ describe('POST cases', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }, }); @@ -78,6 +81,9 @@ describe('POST cases', () => { type: '.jira', fields: { issueType: 'Task', priority: 'High', parent: null }, }, + settings: { + syncAlerts: true, + }, }, }); @@ -108,6 +114,9 @@ describe('POST cases', () => { status: CaseStatuses.open, tags: ['defacement'], connector: null, + settings: { + syncAlerts: true, + }, }, }); @@ -130,6 +139,9 @@ describe('POST cases', () => { title: 'Super Bad Security Issue', tags: ['error'], connector: null, + settings: { + syncAlerts: true, + }, }, }); @@ -160,6 +172,9 @@ describe('POST cases', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }, }); @@ -199,6 +214,9 @@ describe('POST cases', () => { updated_at: null, updated_by: null, version: 'WzksMV0=', + settings: { + syncAlerts: true, + }, }); }); }); diff --git a/x-pack/plugins/case/server/routes/api/utils.test.ts b/x-pack/plugins/case/server/routes/api/utils.test.ts index 7654ae5ff0d1a..405da0df17542 100644 --- a/x-pack/plugins/case/server/routes/api/utils.test.ts +++ b/x-pack/plugins/case/server/routes/api/utils.test.ts @@ -302,6 +302,9 @@ describe('Utils', () => { comments: [], totalComment: 2, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -341,6 +344,9 @@ describe('Utils', () => { comments: [], totalComment: 0, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -387,6 +393,9 @@ describe('Utils', () => { comments: [], totalComment: 0, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }, ]); }); @@ -497,6 +506,9 @@ describe('Utils', () => { comments: [], totalComment: 2, version: 'WzAsMV0=', + settings: { + syncAlerts: true, + }, }); }); }); diff --git a/x-pack/plugins/case/server/saved_object_types/cases.ts b/x-pack/plugins/case/server/saved_object_types/cases.ts index d8ee2f90f3d93..6468d4b3aa61d 100644 --- a/x-pack/plugins/case/server/saved_object_types/cases.ts +++ b/x-pack/plugins/case/server/saved_object_types/cases.ts @@ -134,6 +134,13 @@ export const caseSavedObjectType: SavedObjectsType = { }, }, }, + settings: { + properties: { + syncAlerts: { + type: 'boolean', + }, + }, + }, }, }, migrations: caseMigrations, diff --git a/x-pack/plugins/case/server/saved_object_types/migrations.ts b/x-pack/plugins/case/server/saved_object_types/migrations.ts index 27c363a40af37..9124314ac3f5e 100644 --- a/x-pack/plugins/case/server/saved_object_types/migrations.ts +++ b/x-pack/plugins/case/server/saved_object_types/migrations.ts @@ -9,16 +9,16 @@ import { SavedObjectUnsanitizedDoc, SavedObjectSanitizedDoc } from '../../../../../src/core/server'; import { ConnectorTypes, CommentType } from '../../common/api'; -interface UnsanitizedCase { +interface UnsanitizedCaseConnector { connector_id: string; } -interface UnsanitizedConfigure { +interface UnsanitizedConfigureConnector { connector_id: string; connector_name: string; } -interface SanitizedCase { +interface SanitizedCaseConnector { connector: { id: string; name: string | null; @@ -27,7 +27,7 @@ interface SanitizedCase { }; } -interface SanitizedConfigure { +interface SanitizedConfigureConnector { connector: { id: string; name: string | null; @@ -42,10 +42,16 @@ interface UserActions { old_value: string; } +interface SanitizedCaseSettings { + settings: { + syncAlerts: boolean; + }; +} + export const caseMigrations = { '7.10.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { const { connector_id, ...attributesWithoutConnectorId } = doc.attributes; return { @@ -62,12 +68,26 @@ export const caseMigrations = { references: doc.references || [], }; }, + '7.11.0': ( + doc: SavedObjectUnsanitizedDoc> + ): SavedObjectSanitizedDoc => { + return { + ...doc, + attributes: { + ...doc.attributes, + settings: { + syncAlerts: true, + }, + }, + references: doc.references || [], + }; + }, }; export const configureMigrations = { '7.10.0': ( - doc: SavedObjectUnsanitizedDoc - ): SavedObjectSanitizedDoc => { + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => { const { connector_id, connector_name, ...restAttributes } = doc.attributes; return { diff --git a/x-pack/plugins/case/server/services/alerts/index.ts b/x-pack/plugins/case/server/services/alerts/index.ts new file mode 100644 index 0000000000000..4fb98278b8afa --- /dev/null +++ b/x-pack/plugins/case/server/services/alerts/index.ts @@ -0,0 +1,57 @@ +/* + * 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 type { PublicMethodsOf } from '@kbn/utility-types'; + +import { IClusterClient, KibanaRequest } from 'kibana/server'; +import { CaseStatuses } from '../../../common/api'; + +export type AlertServiceContract = PublicMethodsOf; + +interface UpdateAlertsStatusArgs { + request: KibanaRequest; + ids: string[]; + status: CaseStatuses; + index: string; +} + +export class AlertService { + private isInitialized = false; + private esClient?: IClusterClient; + + constructor() {} + + public initialize(esClient: IClusterClient) { + if (this.isInitialized) { + throw new Error('AlertService already initialized'); + } + + this.isInitialized = true; + this.esClient = esClient; + } + + public async updateAlertsStatus({ request, ids, status, index }: UpdateAlertsStatusArgs) { + if (!this.isInitialized) { + throw new Error('AlertService not initialized'); + } + + // The above check makes sure that esClient is defined. + const result = await this.esClient!.asScoped(request).asCurrentUser.updateByQuery({ + index, + conflicts: 'abort', + body: { + script: { + source: `ctx._source.signal.status = '${status}'`, + lang: 'painless', + }, + query: { ids: { values: ids } }, + }, + ignore_unavailable: true, + }); + + return result; + } +} diff --git a/x-pack/plugins/case/server/services/index.ts b/x-pack/plugins/case/server/services/index.ts index 0ce2b196af471..95bcf87361e07 100644 --- a/x-pack/plugins/case/server/services/index.ts +++ b/x-pack/plugins/case/server/services/index.ts @@ -31,6 +31,7 @@ import { readTags } from './tags/read_tags'; export { CaseConfigureService, CaseConfigureServiceSetup } from './configure'; export { CaseUserActionService, CaseUserActionServiceSetup } from './user_actions'; +export { AlertService, AlertServiceContract } from './alerts'; export interface ClientArgs { client: SavedObjectsClientContract; diff --git a/x-pack/plugins/case/server/services/mocks.ts b/x-pack/plugins/case/server/services/mocks.ts index 287f80a60ab07..01a8cb09ac2d5 100644 --- a/x-pack/plugins/case/server/services/mocks.ts +++ b/x-pack/plugins/case/server/services/mocks.ts @@ -4,11 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CaseConfigureServiceSetup, CaseServiceSetup, CaseUserActionServiceSetup } from '.'; +import { + CaseConfigureServiceSetup, + CaseServiceSetup, + CaseUserActionServiceSetup, + AlertServiceContract, +} from '.'; export type CaseServiceMock = jest.Mocked; export type CaseConfigureServiceMock = jest.Mocked; export type CaseUserActionServiceMock = jest.Mocked; +export type AlertServiceMock = jest.Mocked; export const createCaseServiceMock = (): CaseServiceMock => ({ deleteCase: jest.fn(), @@ -41,3 +47,8 @@ export const createUserActionServiceMock = (): CaseUserActionServiceMock => ({ getUserActions: jest.fn(), postUserActions: jest.fn(), }); + +export const createAlertServiceMock = (): AlertServiceMock => ({ + initialize: jest.fn(), + updateAlertsStatus: jest.fn(), +}); diff --git a/x-pack/plugins/case/server/services/user_actions/helpers.ts b/x-pack/plugins/case/server/services/user_actions/helpers.ts index c9339862b8f24..c7bdc8b10b5a3 100644 --- a/x-pack/plugins/case/server/services/user_actions/helpers.ts +++ b/x-pack/plugins/case/server/services/user_actions/helpers.ts @@ -129,6 +129,7 @@ const userActionFieldsAllowed: UserActionField = [ 'tags', 'title', 'status', + 'settings', ]; export const buildCaseUserActions = ({ diff --git a/x-pack/plugins/case/server/types.ts b/x-pack/plugins/case/server/types.ts index b95060ef30452..d0dfc26aa7b8c 100644 --- a/x-pack/plugins/case/server/types.ts +++ b/x-pack/plugins/case/server/types.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import { AppRequestContext } from '../../security_solution/server/types'; import { CaseClient } from './client'; export interface CaseRequestContext { @@ -13,5 +15,8 @@ export interface CaseRequestContext { declare module 'src/core/server' { interface RequestHandlerContext { case?: CaseRequestContext; + // TODO: Remove when triggers_ui do not import case's types. + // PR https://github.com/elastic/kibana/pull/84587. + securitySolution?: AppRequestContext; } } diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/test/data.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/test/data.ts new file mode 100644 index 0000000000000..e0627c521bb79 --- /dev/null +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/test/data.ts @@ -0,0 +1,173 @@ +/* + * 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 { DatatableColumnType } from '../../../../../../../src/plugins/expressions/common'; +import { + Embeddable, + EmbeddableInput, + EmbeddableOutput, +} from '../../../../../../../src/plugins/embeddable/public'; + +export const createPoint = ({ + field, + value, +}: { + field: string; + value: string | null | number | boolean; +}) => ({ + table: { + columns: [ + { + name: field, + id: '1-1', + meta: { + type: 'date' as DatatableColumnType, + field, + source: 'esaggs', + sourceParams: { + type: 'histogram', + indexPatternId: 'logstash-*', + interval: 30, + otherBucket: true, + }, + }, + }, + ], + rows: [ + { + '1-1': '2048', + }, + ], + }, + column: 0, + row: 0, + value, +}); + +export const rowClickData = { + rowIndex: 1, + table: { + type: 'datatable', + rows: [ + { + '6ced5344-2596-4545-b626-8b449924e2d4': 'IT', + '6890e417-c5f1-4565-a45c-92f55380e14c': '0', + '93b8ef16-2483-45b8-ad27-6cc1f790578b': 13, + 'b0c5dcc2-4012-4d7e-b983-0e089badc43c': 0, + 'e0719f1a-04fb-4036-a63c-c25deac3f011': 7, + }, + { + '6ced5344-2596-4545-b626-8b449924e2d4': 'IT', + '6890e417-c5f1-4565-a45c-92f55380e14c': '2.25', + '93b8ef16-2483-45b8-ad27-6cc1f790578b': 3, + 'b0c5dcc2-4012-4d7e-b983-0e089badc43c': 0, + 'e0719f1a-04fb-4036-a63c-c25deac3f011': 2, + }, + { + '6ced5344-2596-4545-b626-8b449924e2d4': 'IT', + '6890e417-c5f1-4565-a45c-92f55380e14c': '0.020939215995129826', + '93b8ef16-2483-45b8-ad27-6cc1f790578b': 2, + 'b0c5dcc2-4012-4d7e-b983-0e089badc43c': 12.490584373474121, + 'e0719f1a-04fb-4036-a63c-c25deac3f011': 1, + }, + ], + columns: [ + { + id: '6ced5344-2596-4545-b626-8b449924e2d4', + name: 'Top values of DestCountry', + meta: { + type: 'string', + field: 'DestCountry', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: '(missing value)', + }, + }, + source: 'esaggs', + }, + }, + { + id: '6890e417-c5f1-4565-a45c-92f55380e14c', + name: 'Top values of FlightTimeHour', + meta: { + type: 'string', + field: 'FlightTimeHour', + index: 'kibana_sample_data_flights', + params: { + id: 'terms', + params: { + id: 'string', + otherBucketLabel: 'Other', + missingBucketLabel: '(missing value)', + }, + }, + source: 'esaggs', + }, + }, + { + id: '93b8ef16-2483-45b8-ad27-6cc1f790578b', + name: 'Count of records', + meta: { + type: 'number', + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + }, + }, + { + id: 'b0c5dcc2-4012-4d7e-b983-0e089badc43c', + name: 'Average of DistanceMiles', + meta: { + type: 'number', + field: 'DistanceMiles', + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + }, + }, + { + id: 'e0719f1a-04fb-4036-a63c-c25deac3f011', + name: 'Unique count of OriginAirportID', + meta: { + type: 'string', + field: 'OriginAirportID', + index: 'kibana_sample_data_flights', + params: { + id: 'number', + }, + }, + }, + ], + }, + columns: [ + '6ced5344-2596-4545-b626-8b449924e2d4', + '6890e417-c5f1-4565-a45c-92f55380e14c', + '93b8ef16-2483-45b8-ad27-6cc1f790578b', + 'b0c5dcc2-4012-4d7e-b983-0e089badc43c', + 'e0719f1a-04fb-4036-a63c-c25deac3f011', + ], +}; + +interface TestInput extends EmbeddableInput { + savedObjectId?: string; +} + +interface TestOutput extends EmbeddableOutput { + indexPatterns?: Array<{ id: string }>; +} + +export class TestEmbeddable extends Embeddable { + type = 'test'; + + destroy() {} + reload() {} +} diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts index 79d380991f5fd..d9f63f233e1c2 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.test.ts @@ -7,6 +7,11 @@ import { UrlDrilldown, ActionContext, Config } from './url_drilldown'; import { IEmbeddable } from '../../../../../../src/plugins/embeddable/public/lib/embeddables'; import { DatatableColumnType } from '../../../../../../src/plugins/expressions/common'; +import { createPoint, rowClickData, TestEmbeddable } from './test/data'; +import { + VALUE_CLICK_TRIGGER, + ROW_CLICK_TRIGGER, +} from '../../../../../../src/plugins/ui_actions/public'; const mockDataPoints = [ { @@ -99,7 +104,8 @@ describe('UrlDrilldown', () => { embeddable: mockEmbeddable, }; - await expect(urlDrilldown.isCompatible(config, context)).resolves.toBe(true); + const result = urlDrilldown.isCompatible(config, context); + await expect(result).resolves.toBe(true); }); test('not compatible if url is invalid', async () => { @@ -168,4 +174,199 @@ describe('UrlDrilldown', () => { expect(mockNavigateToUrl).not.toBeCalled(); }); }); + + describe('variables', () => { + const embeddable1 = new TestEmbeddable( + { + id: 'test', + title: 'The Title', + savedObjectId: 'SAVED_OBJECT_IDxx', + }, + { + indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }], + } + ); + const data: any = { + data: [ + createPoint({ field: 'field0', value: 'value0' }), + createPoint({ field: 'field1', value: 'value1' }), + createPoint({ field: 'field2', value: 'value2' }), + ], + }; + + const embeddable2 = new TestEmbeddable( + { + id: 'the-id', + query: { + language: 'C++', + query: 'std::cout << 123;', + }, + timeRange: { + from: 'FROM', + to: 'TO', + }, + filters: [ + { + meta: { + alias: 'asdf', + disabled: false, + negate: false, + }, + }, + ], + savedObjectId: 'SAVED_OBJECT_ID', + }, + { + title: 'The Title', + indexPatterns: [ + { id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }, + { id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' }, + ], + } + ); + + describe('getRuntimeVariables()', () => { + test('builds runtime variables for VALUE_CLICK_TRIGGER trigger', () => { + const variables = urlDrilldown.getRuntimeVariables({ + embeddable: embeddable1, + data, + }); + + expect(variables).toMatchObject({ + kibanaUrl: 'http://localhost:5601/', + context: { + panel: { + id: 'test', + title: 'The Title', + savedObjectId: 'SAVED_OBJECT_IDxx', + indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + }, + }, + event: { + key: 'field0', + value: 'value0', + negate: false, + points: [ + { + value: 'value0', + key: 'field0', + }, + { + value: 'value1', + key: 'field1', + }, + { + value: 'value2', + key: 'field2', + }, + ], + }, + }); + }); + + test('builds runtime variables for ROW_CLICK_TRIGGER trigger', () => { + const variables = urlDrilldown.getRuntimeVariables({ + embeddable: embeddable2, + data: rowClickData as any, + }); + + expect(variables).toMatchObject({ + kibanaUrl: 'http://localhost:5601/', + context: { + panel: { + id: 'the-id', + title: 'The Title', + savedObjectId: 'SAVED_OBJECT_ID', + query: { + language: 'C++', + query: 'std::cout << 123;', + }, + timeRange: { + from: 'FROM', + to: 'TO', + }, + filters: [ + { + meta: { + alias: 'asdf', + disabled: false, + negate: false, + }, + }, + ], + }, + }, + event: { + rowIndex: 1, + values: ['IT', '2.25', 3, 0, 2], + keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'], + columnNames: [ + 'Top values of DestCountry', + 'Top values of FlightTimeHour', + 'Count of records', + 'Average of DistanceMiles', + 'Unique count of OriginAirportID', + ], + }, + }); + }); + }); + + describe('getVariableList()', () => { + test('builds variable list for VALUE_CLICK_TRIGGER trigger', () => { + const list = urlDrilldown.getVariableList({ + triggers: [VALUE_CLICK_TRIGGER], + embeddable: embeddable1, + }); + + const expectedList = [ + 'event.key', + 'event.value', + 'event.negate', + 'event.points', + + 'context.panel.id', + 'context.panel.title', + 'context.panel.indexPatternId', + 'context.panel.savedObjectId', + + 'kibanaUrl', + ]; + + for (const expectedItem of expectedList) { + expect(list.includes(expectedItem)).toBe(true); + } + }); + + test('builds variable list for ROW_CLICK_TRIGGER trigger', () => { + const list = urlDrilldown.getVariableList({ + triggers: [ROW_CLICK_TRIGGER], + embeddable: embeddable2, + }); + + const expectedList = [ + 'event.columnNames', + 'event.keys', + 'event.rowIndex', + 'event.values', + + 'context.panel.id', + 'context.panel.title', + 'context.panel.filters', + 'context.panel.query.language', + 'context.panel.query.query', + 'context.panel.indexPatternIds', + 'context.panel.savedObjectId', + 'context.panel.timeRange.from', + 'context.panel.timeRange.to', + + 'kibanaUrl', + ]; + + for (const expectedItem of expectedList) { + expect(list.includes(expectedItem)).toBe(true); + } + }); + }); + }); }); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx index 807dfeed21d1f..3a989c1b0b4cd 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; +import { getFlattenedObject } from '@kbn/std'; import { reactToUiComponent } from '../../../../../../src/plugins/kibana_react/public'; import { ChartActionContext, @@ -13,6 +14,7 @@ import { } from '../../../../../../src/plugins/embeddable/public'; import { CollectConfigProps as CollectConfigPropsBase } from '../../../../../../src/plugins/kibana_utils/public'; import { + ROW_CLICK_TRIGGER, SELECT_RANGE_TRIGGER, VALUE_CLICK_TRIGGER, } from '../../../../../../src/plugins/ui_actions/public'; @@ -22,11 +24,10 @@ import { UrlDrilldownConfig, UrlDrilldownCollectConfig, urlDrilldownValidateUrlTemplate, - urlDrilldownBuildScope, urlDrilldownCompileUrl, UiActionsEnhancedBaseActionFactoryContext as BaseActionFactoryContext, } from '../../../../ui_actions_enhanced/public'; -import { getContextScope, getEventScope, getMockEventScope } from './url_drilldown_scope'; +import { getPanelVariables, getEventScope, getEventVariableList } from './url_drilldown_scope'; import { txtUrlDrilldownDisplayName } from './i18n'; interface UrlDrilldownDeps { @@ -39,9 +40,11 @@ interface UrlDrilldownDeps { export type ActionContext = ChartActionContext; export type Config = UrlDrilldownConfig; export type UrlTrigger = - | typeof CONTEXT_MENU_TRIGGER | typeof VALUE_CLICK_TRIGGER - | typeof SELECT_RANGE_TRIGGER; + | typeof SELECT_RANGE_TRIGGER + | typeof ROW_CLICK_TRIGGER + | typeof CONTEXT_MENU_TRIGGER; + export interface ActionFactoryContext extends BaseActionFactoryContext { embeddable?: IEmbeddable; } @@ -65,7 +68,7 @@ export class UrlDrilldown implements Drilldown = ({ @@ -74,12 +77,12 @@ export class UrlDrilldown implements Drilldown { // eslint-disable-next-line react-hooks/rules-of-hooks - const scope = React.useMemo(() => this.buildEditorScope(context), [context]); + const variables = React.useMemo(() => this.getVariableList(context), [context]); return ( @@ -93,19 +96,13 @@ export class UrlDrilldown implements Drilldown { - const { isValid } = urlDrilldownValidateUrlTemplate(config.url, this.buildEditorScope(context)); - return isValid; + public readonly isConfigValid = (config: Config): config is Config => { + return !!config.url.template; }; public readonly isCompatible = async (config: Config, context: ActionContext) => { - const { isValid, error } = urlDrilldownValidateUrlTemplate( - config.url, - await this.buildRuntimeScope(context) - ); + const scope = this.getRuntimeVariables(context); + const { isValid, error } = urlDrilldownValidateUrlTemplate(config.url, scope); if (!isValid) { // eslint-disable-next-line no-console @@ -117,11 +114,13 @@ export class UrlDrilldown implements Drilldown - urlDrilldownCompileUrl(config.url.template, this.buildRuntimeScope(context)); + public readonly getHref = async (config: Config, context: ActionContext) => { + const scope = this.getRuntimeVariables(context); + return urlDrilldownCompileUrl(config.url.template, scope); + }; public readonly execute = async (config: Config, context: ActionContext) => { - const url = urlDrilldownCompileUrl(config.url.template, this.buildRuntimeScope(context)); + const url = urlDrilldownCompileUrl(config.url.template, this.getRuntimeVariables(context)); if (config.openInNewTab) { window.open(url, '_blank', 'noopener'); } else { @@ -129,19 +128,23 @@ export class UrlDrilldown implements Drilldown { - return urlDrilldownBuildScope({ - globalScope: this.deps.getGlobalScope(), - contextScope: getContextScope(context), - eventScope: getMockEventScope(context.triggers), - }); + public readonly getRuntimeVariables = (context: ActionContext) => { + return { + ...this.deps.getGlobalScope(), + context: { + panel: getPanelVariables(context), + }, + event: getEventScope(context), + }; }; - private buildRuntimeScope = (context: ActionContext) => { - return urlDrilldownBuildScope({ - globalScope: this.deps.getGlobalScope(), - contextScope: getContextScope(context), - eventScope: getEventScope(context), - }); + public readonly getVariableList = (context: ActionFactoryContext): string[] => { + const eventVariables = getEventVariableList(context); + const contextVariables = Object.keys(getFlattenedObject(getPanelVariables(context))).map( + (key) => 'context.panel.' + key + ); + const globalVariables = Object.keys(getFlattenedObject(this.deps.getGlobalScope())); + + return [...eventVariables.sort(), ...contextVariables.sort(), ...globalVariables.sort()]; }; } diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts index a93e150deee8f..5917737d15eda 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.test.ts @@ -6,46 +6,15 @@ import { getEventScope, - getMockEventScope, ValueClickTriggerEventScope, + getEventVariableList, + getPanelVariables, } from './url_drilldown_scope'; -import { DatatableColumnType } from '../../../../../../src/plugins/expressions/common'; - -const createPoint = ({ - field, - value, -}: { - field: string; - value: string | null | number | boolean; -}) => ({ - table: { - columns: [ - { - name: field, - id: '1-1', - meta: { - type: 'date' as DatatableColumnType, - field, - source: 'esaggs', - sourceParams: { - type: 'histogram', - indexPatternId: 'logstash-*', - interval: 30, - otherBucket: true, - }, - }, - }, - ], - rows: [ - { - '1-1': '2048', - }, - ], - }, - column: 0, - row: 0, - value, -}); +import { + RowClickContext, + ROW_CLICK_TRIGGER, +} from '../../../../../../src/plugins/ui_actions/public'; +import { createPoint, rowClickData, TestEmbeddable } from './test/data'; describe('VALUE_CLICK_TRIGGER', () => { describe('supports `points[]`', () => { @@ -80,33 +49,6 @@ describe('VALUE_CLICK_TRIGGER', () => { ] `); }); - - test('getMockEventScope()', () => { - const mockEventScope = getMockEventScope([ - 'VALUE_CLICK_TRIGGER', - ]) as ValueClickTriggerEventScope; - expect(mockEventScope.points.length).toBeGreaterThan(3); - expect(mockEventScope.points).toMatchInlineSnapshot(` - Array [ - Object { - "key": "event.points.0.key", - "value": "event.points.0.value", - }, - Object { - "key": "event.points.1.key", - "value": "event.points.1.value", - }, - Object { - "key": "event.points.2.key", - "value": "event.points.2.value", - }, - Object { - "key": "event.points.3.key", - "value": "event.points.3.value", - }, - ] - `); - }); }); describe('handles undefined, null or missing values', () => { @@ -131,11 +73,221 @@ describe('VALUE_CLICK_TRIGGER', () => { }); }); -describe('CONTEXT_MENU_TRIGGER', () => { - test('getMockEventScope() results in empty scope', () => { - const mockEventScope = getMockEventScope([ - 'CONTEXT_MENU_TRIGGER', - ]) as ValueClickTriggerEventScope; - expect(mockEventScope).toEqual({}); +describe('ROW_CLICK_TRIGGER', () => { + test('getEventVariableList() returns correct list of runtime variables', () => { + const vars = getEventVariableList({ + triggers: [ROW_CLICK_TRIGGER], + }); + expect(vars).toEqual(['event.rowIndex', 'event.values', 'event.keys', 'event.columnNames']); + }); + + test('getEventScope() returns correct variables for row click trigger', () => { + const context = ({ + embeddable: {}, + data: rowClickData as any, + } as unknown) as RowClickContext; + const res = getEventScope(context); + + expect(res).toEqual({ + rowIndex: 1, + values: ['IT', '2.25', 3, 0, 2], + keys: ['DestCountry', 'FlightTimeHour', '', 'DistanceMiles', 'OriginAirportID'], + columnNames: [ + 'Top values of DestCountry', + 'Top values of FlightTimeHour', + 'Count of records', + 'Average of DistanceMiles', + 'Unique count of OriginAirportID', + ], + }); + }); +}); + +describe('getPanelVariables()', () => { + test('returns only ID for empty embeddable', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + {} + ); + const vars = getPanelVariables({ embeddable }); + + expect(vars).toEqual({ + id: 'test', + }); + }); + + test('returns title as specified in input', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + title: 'title1', + }, + {} + ); + const vars = getPanelVariables({ embeddable }); + + expect(vars).toEqual({ + id: 'test', + title: 'title1', + }); + }); + + test('returns output title if input and output titles are specified', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + title: 'title1', + }, + { + title: 'title2', + } + ); + const vars = getPanelVariables({ embeddable }); + + expect(vars).toEqual({ + id: 'test', + title: 'title2', + }); + }); + + test('returns title from output if title in input is missing', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + title: 'title2', + } + ); + const vars = getPanelVariables({ embeddable }); + + expect(vars).toEqual({ + id: 'test', + title: 'title2', + }); + }); + + test('returns saved object ID from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + savedObjectId: '5678', + }, + { + savedObjectId: '1234', + } + ); + const vars = getPanelVariables({ embeddable }); + + expect(vars).toEqual({ + id: 'test', + savedObjectId: '1234', + }); + }); + + test('returns saved object ID from input if it is not set on output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + savedObjectId: '5678', + }, + {} + ); + const vars = getPanelVariables({ embeddable }); + + expect(vars).toEqual({ + id: 'test', + savedObjectId: '5678', + }); + }); + + test('returns query, timeRange and filters from input', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + query: { + language: 'C++', + query: 'std::cout << 123;', + }, + timeRange: { + from: 'FROM', + to: 'TO', + }, + filters: [ + { + meta: { + alias: 'asdf', + disabled: false, + negate: false, + }, + }, + ], + }, + {} + ); + const vars = getPanelVariables({ embeddable }); + + expect(vars).toEqual({ + id: 'test', + query: { + language: 'C++', + query: 'std::cout << 123;', + }, + timeRange: { + from: 'FROM', + to: 'TO', + }, + filters: [ + { + meta: { + alias: 'asdf', + disabled: false, + negate: false, + }, + }, + ], + }); + }); + + test('returns a single index pattern from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + indexPatterns: [{ id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }], + } + ); + const vars = getPanelVariables({ embeddable }); + + expect(vars).toEqual({ + id: 'test', + indexPatternId: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + }); + }); + + test('returns multiple index patterns from output', () => { + const embeddable = new TestEmbeddable( + { + id: 'test', + }, + { + indexPatterns: [ + { id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' }, + { id: 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' }, + ], + } + ); + const vars = getPanelVariables({ embeddable }); + + expect(vars).toEqual({ + id: 'test', + indexPatternIds: [ + 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy', + ], + }); }); }); diff --git a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts index 234af380689e9..3e5fc0a968d39 100644 --- a/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts +++ b/x-pack/plugins/drilldowns/url_drilldown/public/lib/url_drilldown_scope.ts @@ -14,48 +14,54 @@ import { IEmbeddable, isRangeSelectTriggerContext, isValueClickTriggerContext, + isRowClickTriggerContext, isContextMenuTriggerContext, RangeSelectContext, ValueClickContext, + EmbeddableOutput, } from '../../../../../../src/plugins/embeddable/public'; -import type { ActionContext, ActionFactoryContext, UrlTrigger } from './url_drilldown'; +import type { ActionContext, ActionFactoryContext } from './url_drilldown'; import { SELECT_RANGE_TRIGGER, + RowClickContext, VALUE_CLICK_TRIGGER, + ROW_CLICK_TRIGGER, } from '../../../../../../src/plugins/ui_actions/public'; -type ContextScopeInput = ActionContext | ActionFactoryContext; - /** * Part of context scope extracted from an embeddable * Expose on the scope as: `{{context.panel.id}}`, `{{context.panel.filters.[0]}}` */ interface EmbeddableUrlDrilldownContextScope { + /** + * ID of the embeddable panel. + */ id: string; + + /** + * Title of the embeddable panel. + */ title?: string; - query?: Query; - filters?: Filter[]; - timeRange?: TimeRange; - savedObjectId?: string; + /** - * In case panel supports only 1 index patterns + * In case panel supports only 1 index pattern. */ indexPatternId?: string; + /** - * In case panel supports more then 1 index patterns + * In case panel supports more then 1 index pattern. */ indexPatternIds?: string[]; -} -/** - * Url drilldown context scope - * `{{context.$}}` - */ -interface UrlDrilldownContextScope { - panel?: EmbeddableUrlDrilldownContextScope; + query?: Query; + filters?: Filter[]; + timeRange?: TimeRange; + savedObjectId?: string; } -export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilldownContextScope { +export function getPanelVariables(contextScopeInput: { + embeddable?: IEmbeddable; +}): EmbeddableUrlDrilldownContextScope { function hasEmbeddable(val: unknown): val is { embeddable: IEmbeddable } { if (val && typeof val === 'object' && 'embeddable' in val) return true; return false; @@ -64,41 +70,52 @@ export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilld throw new Error( "UrlDrilldown [getContextScope] can't build scope because embeddable object is missing in context" ); - const embeddable = contextScopeInput.embeddable; + + return getEmbeddableVariables(embeddable); +} + +function hasSavedObjectId(obj: Record): obj is { savedObjectId: string } { + return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string'; +} + +/** + * @todo Same functionality is implemented in x-pack/plugins/discover_enhanced/public/actions/explore_data/shared.ts, + * combine both implementations into a common approach. + */ +function getIndexPatternIds(output: EmbeddableOutput): string[] { + function hasIndexPatterns( + _output: Record + ): _output is { indexPatterns: Array<{ id?: string }> } { + return ( + 'indexPatterns' in _output && + Array.isArray(_output.indexPatterns) && + _output.indexPatterns.length > 0 + ); + } + return hasIndexPatterns(output) + ? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[]) + : []; +} + +export function getEmbeddableVariables( + embeddable: IEmbeddable +): EmbeddableUrlDrilldownContextScope { const input = embeddable.getInput(); const output = embeddable.getOutput(); - function hasSavedObjectId(obj: Record): obj is { savedObjectId: string } { - return 'savedObjectId' in obj && typeof obj.savedObjectId === 'string'; - } - function getIndexPatternIds(): string[] { - function hasIndexPatterns( - _output: Record - ): _output is { indexPatterns: Array<{ id?: string }> } { - return ( - 'indexPatterns' in _output && - Array.isArray(_output.indexPatterns) && - _output.indexPatterns.length > 0 - ); - } - return hasIndexPatterns(output) - ? (output.indexPatterns.map((ip) => ip.id).filter(Boolean) as string[]) - : []; - } - const indexPatternsIds = getIndexPatternIds(); - return { - panel: cleanEmptyKeys({ - id: input.id, - title: output.title ?? input.title, - savedObjectId: - output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined), - query: input.query, - timeRange: input.timeRange, - filters: input.filters, - indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined, - indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined, - }), - }; + const indexPatternsIds = getIndexPatternIds(output); + + return deleteUndefinedKeys({ + id: input.id, + title: output.title ?? input.title, + savedObjectId: + output.savedObjectId ?? (hasSavedObjectId(input) ? input.savedObjectId : undefined), + query: input.query, + timeRange: input.timeRange, + filters: input.filters, + indexPatternIds: indexPatternsIds.length > 1 ? indexPatternsIds : undefined, + indexPatternId: indexPatternsIds.length === 1 ? indexPatternsIds[0] : undefined, + }); } /** @@ -108,7 +125,9 @@ export function getContextScope(contextScopeInput: ContextScopeInput): UrlDrilld export type UrlDrilldownEventScope = | ValueClickTriggerEventScope | RangeSelectTriggerEventScope + | RowClickTriggerEventScope | ContextMenuTriggerEventScope; + export type EventScopeInput = ActionContext; export interface ValueClickTriggerEventScope { key?: string; @@ -122,6 +141,12 @@ export interface RangeSelectTriggerEventScope { to?: string | number; } +export interface RowClickTriggerEventScope { + rowIndex: number; + values: Primitive[]; + keys: string[]; + columnNames: string[]; +} export type ContextMenuTriggerEventScope = object; export function getEventScope(eventScopeInput: EventScopeInput): UrlDrilldownEventScope { @@ -129,6 +154,8 @@ export function getEventScope(eventScopeInput: EventScopeInput): UrlDrilldownEve return getEventScopeFromRangeSelectTriggerContext(eventScopeInput); } else if (isValueClickTriggerContext(eventScopeInput)) { return getEventScopeFromValueClickTriggerContext(eventScopeInput); + } else if (isRowClickTriggerContext(eventScopeInput)) { + return getEventScopeFromRowClickTriggerContext(eventScopeInput); } else if (isContextMenuTriggerContext(eventScopeInput)) { return {}; } else { @@ -141,7 +168,7 @@ function getEventScopeFromRangeSelectTriggerContext( ): RangeSelectTriggerEventScope { const { table, column: columnIndex, range } = eventScopeInput.data; const column = table.columns[columnIndex]; - return cleanEmptyKeys({ + return deleteUndefinedKeys({ key: toPrimitiveOrUndefined(column?.meta.field) as string, from: toPrimitiveOrUndefined(range[0]) as string | number | undefined, to: toPrimitiveOrUndefined(range[range.length - 1]) as string | number | undefined, @@ -160,7 +187,7 @@ function getEventScopeFromValueClickTriggerContext( }; }); - return cleanEmptyKeys({ + return deleteUndefinedKeys({ key: points[0]?.key, value: points[0]?.value, negate, @@ -168,37 +195,53 @@ function getEventScopeFromValueClickTriggerContext( }); } -/** - * @remarks - * Difference between `event` and `context` variables, is that real `context` variables are available during drilldown creation (e.g. embeddable panel) - * `event` variables are mapped from trigger context. Since there is no trigger context during drilldown creation, we have to provide some _mock_ variables for validating and previewing the URL - */ -export function getMockEventScope([trigger]: UrlTrigger[]): UrlDrilldownEventScope { - if (trigger === SELECT_RANGE_TRIGGER) { - return { - key: 'event.key', - from: new Date(Date.now() - 15 * 60 * 1000).toISOString(), // 15 minutes ago - to: new Date().toISOString(), - }; +function getEventScopeFromRowClickTriggerContext({ + embeddable, + data, +}: RowClickContext): RowClickTriggerEventScope { + const { rowIndex } = data; + const columns = data.columns || data.table.columns.map(({ id }) => id); + const values: Primitive[] = []; + const keys: string[] = []; + const columnNames: string[] = []; + const row = data.table.rows[rowIndex]; + + for (const columnId of columns) { + const column = data.table.columns.find(({ id }) => id === columnId); + if (!column) { + // This should never happe, but in case it does we log data necessary for debugging. + // eslint-disable-next-line no-console + console.error(data, embeddable ? `Embeddable [${embeddable.getTitle()}]` : null); + throw new Error('Could not find a datatable column.'); + } + values.push(row[columnId]); + keys.push(column.meta.field || ''); + columnNames.push(column.name || column.meta.field || ''); } - if (trigger === VALUE_CLICK_TRIGGER) { - // number of mock points to generate - // should be larger or equal of any possible data points length emitted by VALUE_CLICK_TRIGGER - const nPoints = 4; - const points = new Array(nPoints).fill(0).map((_, index) => ({ - key: `event.points.${index}.key`, - value: `event.points.${index}.value`, - })); - return { - key: `event.key`, - value: `event.value`, - negate: false, - points, - }; + const scope: RowClickTriggerEventScope = { + rowIndex, + values, + keys, + columnNames, + }; + + return scope; +} + +export function getEventVariableList(context: ActionFactoryContext): string[] { + const [trigger] = context.triggers; + + switch (trigger) { + case SELECT_RANGE_TRIGGER: + return ['event.key', 'event.from', 'event.to']; + case VALUE_CLICK_TRIGGER: + return ['event.key', 'event.value', 'event.negate', 'event.points']; + case ROW_CLICK_TRIGGER: + return ['event.rowIndex', 'event.values', 'event.keys', 'event.columnNames']; } - return {}; + return []; } type Primitive = string | number | boolean | null; @@ -210,7 +253,7 @@ function toPrimitiveOrUndefined(v: unknown): Primitive | undefined { return String(v); } -function cleanEmptyKeys>(obj: T): T { +function deleteUndefinedKeys>(obj: T): T { Object.keys(obj).forEach((key) => { if (obj[key] === undefined) { delete obj[key]; diff --git a/x-pack/plugins/fleet/common/types/models/epm.ts b/x-pack/plugins/fleet/common/types/models/epm.ts index 96868fa8cfc3b..f518c606d6959 100644 --- a/x-pack/plugins/fleet/common/types/models/epm.ts +++ b/x-pack/plugins/fleet/common/types/models/epm.ts @@ -207,6 +207,7 @@ export type ElasticsearchAssetTypeToParts = Record< export interface RegistryDataStream { type: string; + hidden?: boolean; dataset: string; title: string; release: string; @@ -319,7 +320,7 @@ export interface IndexTemplate { mappings: any; aliases: object; }; - data_stream: object; + data_stream: { hidden?: boolean }; composed_of: string[]; _meta: object; } diff --git a/x-pack/plugins/fleet/server/errors/handlers.ts b/x-pack/plugins/fleet/server/errors/handlers.ts index 222554e97eb91..fecfcf145ca99 100644 --- a/x-pack/plugins/fleet/server/errors/handlers.ts +++ b/x-pack/plugins/fleet/server/errors/handlers.ts @@ -20,6 +20,7 @@ import { PackageNotFoundError, AgentPolicyNameExistsError, PackageUnsupportedMediaTypeError, + ConcurrentInstallOperationError, } from './index'; type IngestErrorHandler = ( @@ -69,7 +70,9 @@ const getHTTPResponseCode = (error: IngestManagerError): number => { if (error instanceof PackageUnsupportedMediaTypeError) { return 415; // Unsupported Media Type } - + if (error instanceof ConcurrentInstallOperationError) { + return 409; // Conflict + } return 400; // Bad Request }; diff --git a/x-pack/plugins/fleet/server/errors/index.ts b/x-pack/plugins/fleet/server/errors/index.ts index fad4eef66215d..700750761def4 100644 --- a/x-pack/plugins/fleet/server/errors/index.ts +++ b/x-pack/plugins/fleet/server/errors/index.ts @@ -30,3 +30,4 @@ export class PackageInvalidArchiveError extends IngestManagerError {} export class PackageCacheError extends IngestManagerError {} export class PackageOperationNotSupportedError extends IngestManagerError {} export class FleetAdminUserInvalidError extends IngestManagerError {} +export class ConcurrentInstallOperationError extends IngestManagerError {} diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts index 199026da30c11..944f742e54546 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/install.ts @@ -314,6 +314,7 @@ export async function installTemplate({ pipelineName, packageName, composedOfTemplates, + hidden: dataStream.hidden, }); // TODO: Check return values for errors diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts index cc1aa79c7491c..bdff7e0fb3bc6 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.test.ts @@ -60,6 +60,31 @@ test('adds empty composed_of correctly', () => { expect(template.composed_of).toStrictEqual(composedOfTemplates); }); +test('adds hidden field correctly', () => { + const templateWithHiddenName = 'logs-nginx-access-abcd'; + + const templateWithHidden = getTemplate({ + type: 'logs', + templateName: templateWithHiddenName, + packageName: 'nginx', + mappings: { properties: {} }, + composedOfTemplates: [], + hidden: true, + }); + expect(templateWithHidden.data_stream.hidden).toEqual(true); + + const templateWithoutHiddenName = 'logs-nginx-access-efgh'; + + const templateWithoutHidden = getTemplate({ + type: 'logs', + templateName: templateWithoutHiddenName, + packageName: 'nginx', + mappings: { properties: {} }, + composedOfTemplates: [], + }); + expect(templateWithoutHidden.data_stream.hidden).toEqual(undefined); +}); + test('tests loading base.yml', () => { const ymlPath = path.join(__dirname, '../../fields/tests/base.yml'); const fieldsYML = readFileSync(ymlPath, 'utf-8'); diff --git a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts index 8d33180d6262d..d80d54d098db7 100644 --- a/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/fleet/server/services/epm/elasticsearch/template/template.ts @@ -45,6 +45,7 @@ export function getTemplate({ pipelineName, packageName, composedOfTemplates, + hidden, }: { type: string; templateName: string; @@ -52,8 +53,16 @@ export function getTemplate({ pipelineName?: string | undefined; packageName: string; composedOfTemplates: string[]; + hidden?: boolean; }): IndexTemplate { - const template = getBaseTemplate(type, templateName, mappings, packageName, composedOfTemplates); + const template = getBaseTemplate( + type, + templateName, + mappings, + packageName, + composedOfTemplates, + hidden + ); if (pipelineName) { template.template.settings.index.default_pipeline = pipelineName; } @@ -253,7 +262,8 @@ function getBaseTemplate( templateName: string, mappings: IndexTemplateMappings, packageName: string, - composedOfTemplates: string[] + composedOfTemplates: string[], + hidden?: boolean ): IndexTemplate { // Meta information to identify Ingest Manager's managed templates and indices const _meta = { @@ -324,7 +334,7 @@ function getBaseTemplate( // To be filled with the aliases that we need aliases: {}, }, - data_stream: {}, + data_stream: { hidden }, composed_of: composedOfTemplates, _meta, }; diff --git a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts index b0a76016e8eee..5e6ecad9b72f1 100644 --- a/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts +++ b/x-pack/plugins/fleet/server/services/epm/packages/_install_package.ts @@ -30,6 +30,7 @@ import { deleteKibanaSavedObjectsAssets } from './remove'; import { installTransform } from '../elasticsearch/transform/install'; import { createInstallation, saveKibanaAssetsRefs, updateVersion } from './install'; import { saveArchiveEntries } from '../archive/storage'; +import { ConcurrentInstallOperationError } from '../../../errors'; // this is only exported for testing // use a leading underscore to indicate it's not the supported path @@ -53,163 +54,176 @@ export async function _installPackage({ installSource: InstallSource; }): Promise { const { name: pkgName, version: pkgVersion } = packageInfo; - // if some installation already exists - if (installedPkg) { - // if the installation is currently running, don't try to install - // instead, only return already installed assets - if ( - installedPkg.attributes.install_status === 'installing' && - Date.now() - Date.parse(installedPkg.attributes.install_started_at) < - MAX_TIME_COMPLETE_INSTALL - ) { - let assets: AssetReference[] = []; - assets = assets.concat(installedPkg.attributes.installed_es); - assets = assets.concat(installedPkg.attributes.installed_kibana); - return assets; + try { + // if some installation already exists + if (installedPkg) { + // if the installation is currently running, don't try to install + // instead, only return already installed assets + if ( + installedPkg.attributes.install_status === 'installing' && + Date.now() - Date.parse(installedPkg.attributes.install_started_at) < + MAX_TIME_COMPLETE_INSTALL + ) { + throw new ConcurrentInstallOperationError( + `Concurrent installation or upgrade of ${pkgName || 'unknown'}-${ + pkgVersion || 'unknown' + } detected, aborting.` + ); + } else { + // if no installation is running, or the installation has been running longer than MAX_TIME_COMPLETE_INSTALL + // (it might be stuck) update the saved object and proceed + await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { + install_version: pkgVersion, + install_status: 'installing', + install_started_at: new Date().toISOString(), + install_source: installSource, + }); + } } else { - // if no installation is running, or the installation has been running longer than MAX_TIME_COMPLETE_INSTALL - // (it might be stuck) update the saved object and proceed - await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { - install_version: pkgVersion, - install_status: 'installing', - install_started_at: new Date().toISOString(), - install_source: installSource, + await createInstallation({ + savedObjectsClient, + packageInfo, + installSource, }); } - } else { - await createInstallation({ - savedObjectsClient, - packageInfo, - installSource, - }); - } - // kick off `installIndexPatterns` & `installKibanaAssets` as early as possible because they're the longest running operations - // we don't `await` here because we don't want to delay starting the many other `install*` functions - // however, without an `await` or a `.catch` we haven't defined how to handle a promise rejection - // we define it many lines and potentially seconds of wall clock time later in - // `await Promise.all([installKibanaAssetsPromise, installIndexPatternPromise]);` - // if we encounter an error before we there, we'll have an "unhandled rejection" which causes its own problems - // the program will log something like this _and exit/crash_ - // Unhandled Promise rejection detected: - // RegistryResponseError or some other error - // Terminating process... - // server crashed with status code 1 - // - // add a `.catch` to prevent the "unhandled rejection" case - // in that `.catch`, set something that indicates a failure - // check for that failure later and act accordingly (throw, ignore, return) - let installIndexPatternError; - const installIndexPatternPromise = installIndexPatterns( - savedObjectsClient, - pkgName, - pkgVersion, - installSource - ).catch((reason) => (installIndexPatternError = reason)); - const kibanaAssets = await getKibanaAssets(paths); - if (installedPkg) - await deleteKibanaSavedObjectsAssets( + // kick off `installIndexPatterns` & `installKibanaAssets` as early as possible because they're the longest running operations + // we don't `await` here because we don't want to delay starting the many other `install*` functions + // however, without an `await` or a `.catch` we haven't defined how to handle a promise rejection + // we define it many lines and potentially seconds of wall clock time later in + // `await Promise.all([installKibanaAssetsPromise, installIndexPatternPromise]);` + // if we encounter an error before we there, we'll have an "unhandled rejection" which causes its own problems + // the program will log something like this _and exit/crash_ + // Unhandled Promise rejection detected: + // RegistryResponseError or some other error + // Terminating process... + // server crashed with status code 1 + // + // add a `.catch` to prevent the "unhandled rejection" case + // in that `.catch`, set something that indicates a failure + // check for that failure later and act accordingly (throw, ignore, return) + let installIndexPatternError; + const installIndexPatternPromise = installIndexPatterns( savedObjectsClient, - installedPkg.attributes.installed_kibana + pkgName, + pkgVersion, + installSource + ).catch((reason) => (installIndexPatternError = reason)); + const kibanaAssets = await getKibanaAssets(paths); + if (installedPkg) + await deleteKibanaSavedObjectsAssets( + savedObjectsClient, + installedPkg.attributes.installed_kibana + ); + // save new kibana refs before installing the assets + const installedKibanaAssetsRefs = await saveKibanaAssetsRefs( + savedObjectsClient, + pkgName, + kibanaAssets ); - // save new kibana refs before installing the assets - const installedKibanaAssetsRefs = await saveKibanaAssetsRefs( - savedObjectsClient, - pkgName, - kibanaAssets - ); - let installKibanaAssetsError; - const installKibanaAssetsPromise = installKibanaAssets({ - savedObjectsClient, - pkgName, - kibanaAssets, - }).catch((reason) => (installKibanaAssetsError = reason)); - - // the rest of the installation must happen in sequential order - // currently only the base package has an ILM policy - // at some point ILM policies can be installed/modified - // per data stream and we should then save them - await installILMPolicy(paths, callCluster); - - // installs versionized pipelines without removing currently installed ones - const installedPipelines = await installPipelines( - packageInfo, - paths, - callCluster, - savedObjectsClient - ); - // install or update the templates referencing the newly installed pipelines - const installedTemplates = await installTemplates( - packageInfo, - callCluster, - paths, - savedObjectsClient - ); - - // update current backing indices of each data stream - await updateCurrentWriteIndices(callCluster, installedTemplates); + let installKibanaAssetsError; + const installKibanaAssetsPromise = installKibanaAssets({ + savedObjectsClient, + pkgName, + kibanaAssets, + }).catch((reason) => (installKibanaAssetsError = reason)); - const installedTransforms = await installTransform( - packageInfo, - paths, - callCluster, - savedObjectsClient - ); + // the rest of the installation must happen in sequential order + // currently only the base package has an ILM policy + // at some point ILM policies can be installed/modified + // per data stream and we should then save them + await installILMPolicy(paths, callCluster); - // if this is an update or retrying an update, delete the previous version's pipelines - if ((installType === 'update' || installType === 'reupdate') && installedPkg) { - await deletePreviousPipelines( + // installs versionized pipelines without removing currently installed ones + const installedPipelines = await installPipelines( + packageInfo, + paths, callCluster, - savedObjectsClient, - pkgName, - installedPkg.attributes.version + savedObjectsClient ); - } - // pipelines from a different version may have installed during a failed update - if (installType === 'rollback' && installedPkg) { - await deletePreviousPipelines( + // install or update the templates referencing the newly installed pipelines + const installedTemplates = await installTemplates( + packageInfo, callCluster, - savedObjectsClient, - pkgName, - installedPkg.attributes.install_version + paths, + savedObjectsClient ); - } - const installedTemplateRefs = installedTemplates.map((template) => ({ - id: template.templateName, - type: ElasticsearchAssetType.indexTemplate, - })); - // make sure the assets are installed (or didn't error) - if (installIndexPatternError) throw installIndexPatternError; - if (installKibanaAssetsError) throw installKibanaAssetsError; - await Promise.all([installKibanaAssetsPromise, installIndexPatternPromise]); + // update current backing indices of each data stream + await updateCurrentWriteIndices(callCluster, installedTemplates); - const packageAssetResults = await saveArchiveEntries({ - savedObjectsClient, - paths, - packageInfo, - installSource, - }); - const packageAssetRefs: PackageAssetReference[] = packageAssetResults.saved_objects.map( - (result) => ({ - id: result.id, - type: ASSETS_SAVED_OBJECT_TYPE, - }) - ); + const installedTransforms = await installTransform( + packageInfo, + paths, + callCluster, + savedObjectsClient + ); - // update to newly installed version when all assets are successfully installed - if (installedPkg) await updateVersion(savedObjectsClient, pkgName, pkgVersion); + // if this is an update or retrying an update, delete the previous version's pipelines + if ((installType === 'update' || installType === 'reupdate') && installedPkg) { + await deletePreviousPipelines( + callCluster, + savedObjectsClient, + pkgName, + installedPkg.attributes.version + ); + } + // pipelines from a different version may have installed during a failed update + if (installType === 'rollback' && installedPkg) { + await deletePreviousPipelines( + callCluster, + savedObjectsClient, + pkgName, + installedPkg.attributes.install_version + ); + } + const installedTemplateRefs = installedTemplates.map((template) => ({ + id: template.templateName, + type: ElasticsearchAssetType.indexTemplate, + })); - await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { - install_version: pkgVersion, - install_status: 'installed', - package_assets: packageAssetRefs, - }); + // make sure the assets are installed (or didn't error) + if (installIndexPatternError) throw installIndexPatternError; + if (installKibanaAssetsError) throw installKibanaAssetsError; + await Promise.all([installKibanaAssetsPromise, installIndexPatternPromise]); - return [ - ...installedKibanaAssetsRefs, - ...installedPipelines, - ...installedTemplateRefs, - ...installedTransforms, - ]; + const packageAssetResults = await saveArchiveEntries({ + savedObjectsClient, + paths, + packageInfo, + installSource, + }); + const packageAssetRefs: PackageAssetReference[] = packageAssetResults.saved_objects.map( + (result) => ({ + id: result.id, + type: ASSETS_SAVED_OBJECT_TYPE, + }) + ); + + // update to newly installed version when all assets are successfully installed + if (installedPkg) await updateVersion(savedObjectsClient, pkgName, pkgVersion); + + await savedObjectsClient.update(PACKAGES_SAVED_OBJECT_TYPE, pkgName, { + install_version: pkgVersion, + install_status: 'installed', + package_assets: packageAssetRefs, + }); + + return [ + ...installedKibanaAssetsRefs, + ...installedPipelines, + ...installedTemplateRefs, + ...installedTransforms, + ]; + } catch (err) { + if (savedObjectsClient.errors.isConflictError(err)) { + throw new ConcurrentInstallOperationError( + `Concurrent installation or upgrade of ${pkgName || 'unknown'}-${ + pkgVersion || 'unknown' + } detected, aborting. Original error: ${err.message}` + ); + } else { + throw err; + } + } } diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 8ce7e835dd858..7206fbfd547d4 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -111,6 +111,8 @@ export const setup = async (arg?: { appServicesContext: Partial { @@ -197,6 +199,10 @@ export const setup = async (arg?: { appServicesContext: Partial exists('freezeSwitch'); + const setReadonly = (phase: Phases) => async (value: boolean) => { + await createFormToggleAction(`${phase}-readonlySwitch`)(value); + }; + const createSearchableSnapshotActions = (phase: Phases) => { const fieldSelector = `searchableSnapshotField-${phase}`; const licenseCalloutSelector = `${fieldSelector}.searchableSnapshotDisabledDueToLicense`; @@ -235,10 +241,12 @@ export const setup = async (arg?: { appServicesContext: Partial', () => { test('setting all values', async () => { const { actions } = testBed; + await actions.hot.toggleDefaultRollover(false); await actions.hot.setMaxSize('123', 'mb'); await actions.hot.setMaxDocs('123'); await actions.hot.setMaxAge('123', 'h'); @@ -126,6 +127,7 @@ describe('', () => { await actions.hot.setForcemergeSegmentsCount('123'); await actions.hot.setBestCompression(true); await actions.hot.setShrink('2'); + await actions.hot.setReadonly(true); await actions.hot.setIndexPriority('123'); await actions.savePolicy(); @@ -141,6 +143,7 @@ describe('', () => { "index_codec": "best_compression", "max_num_segments": 123, }, + "readonly": Object {}, "rollover": Object { "max_age": "123h", "max_docs": 123, @@ -175,7 +178,8 @@ describe('', () => { test('disabling rollover', async () => { const { actions } = testBed; - await actions.hot.toggleRollover(true); + await actions.hot.toggleDefaultRollover(false); + await actions.hot.toggleRollover(false); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; const policy = JSON.parse(JSON.parse(latestRequest.requestBody).body); @@ -210,6 +214,17 @@ describe('', () => { expect(actions.cold.searchableSnapshotsExists()).toBeTruthy(); expect(actions.cold.freezeExists()).toBeFalsy(); }); + + test('disabling rollover toggle, but enabling default rollover', async () => { + const { actions } = testBed; + await actions.hot.toggleDefaultRollover(false); + await actions.hot.toggleRollover(false); + await actions.hot.toggleDefaultRollover(true); + + expect(actions.hot.forceMergeFieldExists()).toBeTruthy(); + expect(actions.hot.shrinkExists()).toBeTruthy(); + expect(actions.hot.searchableSnapshotsExists()).toBeTruthy(); + }); }); }); @@ -259,6 +274,7 @@ describe('', () => { await actions.warm.toggleForceMerge(true); await actions.warm.setForcemergeSegmentsCount('123'); await actions.warm.setBestCompression(true); + await actions.warm.setReadonly(true); await actions.warm.setIndexPriority('123'); await actions.savePolicy(); const latestRequest = server.requests[server.requests.length - 1]; @@ -292,6 +308,7 @@ describe('', () => { "index_codec": "best_compression", "max_num_segments": 123, }, + "readonly": Object {}, "set_priority": Object { "priority": 123, }, @@ -762,7 +779,7 @@ describe('', () => { await act(async () => { testBed = await setup({ appServicesContext: { - license: licensingMock.createLicense({ license: { type: 'basic' } }), + license: licensingMock.createLicense({ license: { type: 'enterprise' } }), }, }); }); @@ -772,11 +789,12 @@ describe('', () => { }); test('hiding and disabling searchable snapshot field', async () => { const { actions } = testBed; + await actions.hot.toggleDefaultRollover(false); await actions.hot.toggleRollover(false); await actions.cold.enable(true); expect(actions.hot.searchableSnapshotsExists()).toBeFalsy(); - expect(actions.cold.searchableSnapshotDisabledDueToLicense()).toBeTruthy(); + expect(actions.cold.searchableSnapshotDisabledDueToRollover()).toBeTruthy(); }); }); }); diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index d7d38e3b92516..c54ccb9f85edf 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -113,7 +113,14 @@ const expectedErrorMessages = (rendered: ReactWrapper, expectedMessages: string[ expect(foundErrorMessage).toBe(true); }); }; +const noDefaultRollover = async (rendered: ReactWrapper) => { + await act(async () => { + findTestSubject(rendered, 'useDefaultRolloverSwitch').simulate('click'); + }); + rendered.update(); +}; const noRollover = async (rendered: ReactWrapper) => { + await noDefaultRollover(rendered); await act(async () => { findTestSubject(rendered, 'rolloverSwitch').simulate('click'); }); @@ -326,6 +333,7 @@ describe('edit policy', () => { describe('hot phase', () => { test('should show errors when trying to save with no max size, no max age and no max docs', async () => { const rendered = mountWithIntl(component); + await noDefaultRollover(rendered); expect(findTestSubject(rendered, 'rolloverSettingsRequired').exists()).toBeFalsy(); await setPolicyName(rendered, 'mypolicy'); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); @@ -349,6 +357,7 @@ describe('edit policy', () => { test('should show number above 0 required error when trying to save with -1 for max size', async () => { const rendered = mountWithIntl(component); await setPolicyName(rendered, 'mypolicy'); + await noDefaultRollover(rendered); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '-1' } }); @@ -360,6 +369,7 @@ describe('edit policy', () => { test('should show number above 0 required error when trying to save with 0 for max size', async () => { const rendered = mountWithIntl(component); await setPolicyName(rendered, 'mypolicy'); + await noDefaultRollover(rendered); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '-1' } }); @@ -370,6 +380,7 @@ describe('edit policy', () => { test('should show number above 0 required error when trying to save with -1 for max age', async () => { const rendered = mountWithIntl(component); await setPolicyName(rendered, 'mypolicy'); + await noDefaultRollover(rendered); const maxAgeInput = findTestSubject(rendered, 'hot-selectedMaxAge'); await act(async () => { maxAgeInput.simulate('change', { target: { value: '-1' } }); @@ -380,6 +391,7 @@ describe('edit policy', () => { test('should show number above 0 required error when trying to save with 0 for max age', async () => { const rendered = mountWithIntl(component); await setPolicyName(rendered, 'mypolicy'); + await noDefaultRollover(rendered); const maxAgeInput = findTestSubject(rendered, 'hot-selectedMaxAge'); await act(async () => { maxAgeInput.simulate('change', { target: { value: '0' } }); diff --git a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts index 1c28262a54305..58468f06e3b2d 100644 --- a/x-pack/plugins/index_lifecycle_management/common/types/policies.ts +++ b/x-pack/plugins/index_lifecycle_management/common/types/policies.ts @@ -57,15 +57,19 @@ export interface SearchableSnapshotAction { force_merge_index?: boolean; } +export interface RolloverAction { + max_size?: string; + max_age?: string; + max_docs?: number; +} + export interface SerializedHotPhase extends SerializedPhase { actions: { - rollover?: { - max_size?: string; - max_age?: string; - max_docs?: number; - }; + rollover?: RolloverAction; forcemerge?: ForcemergeAction; + readonly?: {}; shrink?: ShrinkAction; + set_priority?: { priority: number | null; }; @@ -81,6 +85,7 @@ export interface SerializedWarmPhase extends SerializedPhase { allocate?: AllocateAction; shrink?: ShrinkAction; forcemerge?: ForcemergeAction; + readonly?: {}; set_priority?: { priority: number | null; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts index 23d7387aa7076..a892a7a031a87 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/constants/policy.ts @@ -4,21 +4,28 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SerializedPhase, DeletePhase, SerializedPolicy } from '../../../common/types'; +import { + SerializedPhase, + DeletePhase, + SerializedPolicy, + RolloverAction, +} from '../../../common/types'; export const defaultSetPriority: string = '100'; export const defaultPhaseIndexPriority: string = '50'; +export const defaultRolloverAction: RolloverAction = { + max_age: '30d', + max_size: '50gb', +}; + export const defaultPolicy: SerializedPolicy = { name: '', phases: { hot: { actions: { - rollover: { - max_age: '30d', - max_size: '50gb', - }, + rollover: defaultRolloverAction, }, }, }, diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts index 1dabae1a0f0c4..274905342f815 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/index.ts @@ -5,3 +5,5 @@ */ export * from './data_tiers'; + +export * from './rollover'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/rollover.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/rollover.ts new file mode 100644 index 0000000000000..1b85303c4bce0 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/rollover.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 { SerializedPolicy } from '../../../common/types'; +import { defaultRolloverAction } from '../constants'; + +export const isUsingDefaultRollover = (policy: SerializedPolicy): boolean => { + const rollover = policy?.phases?.hot?.actions?.rollover; + return Boolean( + rollover && + rollover.max_age === defaultRolloverAction.max_age && + rollover.max_docs === defaultRolloverAction.max_docs && + rollover.max_size === defaultRolloverAction.max_size + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_row/described_form_row.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_row/described_form_row.tsx index 98c63437659fd..161729ae48057 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_row/described_form_row.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/described_form_row/described_form_row.tsx @@ -55,7 +55,9 @@ export const DescribedFormRow: FunctionComponent = ({ const [uncontrolledIsContentVisible, setUncontrolledIsContentVisible] = useState( () => switchProps?.initialValue ?? false ); - const isContentVisible = Boolean(switchProps?.checked ?? uncontrolledIsContentVisible); + const isContentVisible = Boolean( + switchProps === undefined || (switchProps?.checked ?? uncontrolledIsContentVisible) + ); const renderToggle = () => { if (!switchProps) { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index a4fb03bbd1ca6..ae8fecd1a1958 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -16,6 +16,8 @@ import { EuiCallOut, EuiAccordion, EuiTextColor, + EuiSwitch, + EuiIconTip, } from '@elastic/eui'; import { Phases } from '../../../../../../../common/types'; @@ -24,19 +26,19 @@ import { useFormData, UseField, SelectField, NumericField } from '../../../../.. import { i18nTexts } from '../../../i18n_texts'; -import { ROLLOVER_EMPTY_VALIDATION } from '../../../form'; +import { ROLLOVER_EMPTY_VALIDATION, useConfigurationIssues } from '../../../form'; import { useEditPolicyContext } from '../../../edit_policy_context'; -import { ROLLOVER_FORM_PATHS } from '../../../constants'; +import { ROLLOVER_FORM_PATHS, isUsingDefaultRolloverPath } from '../../../constants'; -import { LearnMoreLink, ActiveBadge, ToggleFieldWithDescribedFormRow } from '../../'; +import { LearnMoreLink, ActiveBadge, DescribedFormRow } from '../../'; import { ForcemergeField, SetPriorityInputField, SearchableSnapshotField, - useRolloverPath, + ReadonlyField, ShrinkField, } from '../shared_fields'; @@ -47,9 +49,10 @@ const hotProperty: keyof Phases = 'hot'; export const HotPhase: FunctionComponent = () => { const { license } = useEditPolicyContext(); const [formData] = useFormData({ - watch: useRolloverPath, + watch: isUsingDefaultRolloverPath, }); - const isRolloverEnabled = get(formData, useRolloverPath); + const { isUsingRollover } = useConfigurationIssues(); + const isUsingDefaultRollover = get(formData, isUsingDefaultRolloverPath); const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); return ( @@ -88,7 +91,7 @@ export const HotPhase: FunctionComponent = () => { })} paddingSize="m" > - {i18n.translate('xpack.indexLifecycleMgmt.hotPhase.rolloverFieldTitle', { @@ -97,147 +100,197 @@ export const HotPhase: FunctionComponent = () => { } description={ - -

- {' '} - + +

+ {' '} + + } + docPath="indices-rollover-index.html" + /> +

+
+ + path={isUsingDefaultRolloverPath}> + {(field) => ( + <> + field.setValue(e.target.checked)} + data-test-subj="useDefaultRolloverSwitch" + /> +   + + } /> - } - docPath="indices-rollover-index.html" - /> -

- + + )} + + } - switchProps={{ - path: '_meta.hot.useRollover', - 'data-test-subj': 'rolloverSwitch', - }} fullWidth > - {isRolloverEnabled && ( - <> - - {showEmptyRolloverFieldsError && ( +
+ path="_meta.hot.useRollover"> + {(field) => ( <> - -
{i18nTexts.editPolicy.errors.rollOverConfigurationCallout.body}
-
- - - )} - - - - {(field) => { - const showErrorCallout = field.errors.some( - (e) => e.code === ROLLOVER_EMPTY_VALIDATION - ); - if (showErrorCallout !== showEmptyRolloverFieldsError) { - setShowEmptyRolloverFieldsError(showErrorCallout); - } - return ( - - ); - }} - - - - field.setValue(e.target.checked)} + data-test-subj="rolloverSwitch" /> - - - - - - - - - - - - + } /> - - - - - - - )} - - {isRolloverEnabled && ( + + )} + + {isUsingRollover && ( + <> + + {showEmptyRolloverFieldsError && ( + <> + +
{i18nTexts.editPolicy.errors.rollOverConfigurationCallout.body}
+
+ + + )} + + + + {(field) => { + const showErrorCallout = field.errors.some( + (e) => e.code === ROLLOVER_EMPTY_VALIDATION + ); + if (showErrorCallout !== showEmptyRolloverFieldsError) { + setShowEmptyRolloverFieldsError(showErrorCallout); + } + return ( + + ); + }} + + + + + + + + + + + + + + + + + + + + + + + )} +
+ + {isUsingRollover && ( <> {} {license.canUseSearchableSnapshot() && } + )} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts index 503cd65da655b..15167672265fd 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/index.ts @@ -4,8 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -export { useRolloverPath } from '../../../constants'; - export { DataTierAllocationField } from './data_tier_allocation_field'; export { ForcemergeField } from './forcemerge_field'; @@ -19,3 +17,5 @@ export { SnapshotPoliciesField } from './snapshot_policies_field'; export { ShrinkField } from './shrink_field'; export { SearchableSnapshotField } from './searchable_snapshot_field'; + +export { ReadonlyField } from './readonly_field'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/min_age_input_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/min_age_input_field.tsx index f37c387354418..59086ce572252 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/min_age_input_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/min_age_input_field/min_age_input_field.tsx @@ -4,21 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ import React, { FunctionComponent } from 'react'; -import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { - useFormData, - UseField, - NumericField, - SelectField, -} from '../../../../../../../shared_imports'; +import { UseField, NumericField, SelectField } from '../../../../../../../shared_imports'; import { LearnMoreLink } from '../../../learn_more_link'; -import { useRolloverPath } from '../../../../constants'; +import { useConfigurationIssues } from '../../../../form'; import { getUnitsAriaLabelForPhase, getTimingLabelForPhase } from './util'; @@ -29,8 +23,7 @@ interface Props { } export const MinAgeInputField: FunctionComponent = ({ phase }): React.ReactElement => { - const [formData] = useFormData({ watch: useRolloverPath }); - const rolloverEnabled = get(formData, useRolloverPath); + const { isUsingRollover: rolloverEnabled } = useConfigurationIssues(); let daysOptionLabel; let hoursOptionLabel; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/readonly_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/readonly_field.tsx new file mode 100644 index 0000000000000..16f78cc904295 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/readonly_field.tsx @@ -0,0 +1,47 @@ +/* + * 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 { FormattedMessage } from '@kbn/i18n/react'; +import { EuiTextColor } from '@elastic/eui'; +import { LearnMoreLink } from '../../learn_more_link'; +import { ToggleFieldWithDescribedFormRow } from '../../described_form_row'; + +interface Props { + phase: 'hot' | 'warm'; +} + +export const ReadonlyField: React.FunctionComponent = ({ phase }) => { + return ( + + + + } + description={ + + {' '} + + + } + fullWidth + titleSize="xs" + switchProps={{ + 'data-test-subj': `${phase}-readonlySwitch`, + path: `_meta.${phase}.readonlyEnabled`, + }} + > +
+ + ); +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx index 2a55cee0794c5..3157c0a51accf 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/searchable_snapshot_field/searchable_snapshot_field.tsx @@ -29,8 +29,6 @@ import { useConfigurationIssues } from '../../../../form'; import { i18nTexts } from '../../../../i18n_texts'; -import { useRolloverPath } from '../../../../constants'; - import { FieldLoadingError, DescribedFormRow, LearnMoreLink } from '../../../'; import { SearchableSnapshotDataProvider } from './searchable_snapshot_data_provider'; @@ -54,17 +52,16 @@ export const SearchableSnapshotField: FunctionComponent = ({ phase }) => services: { cloud }, } = useKibana(); const { getUrlForApp, policy, license } = useEditPolicyContext(); - const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); + const { isUsingSearchableSnapshotInHotPhase, isUsingRollover } = useConfigurationIssues(); const searchableSnapshotPath = `phases.${phase}.actions.searchable_snapshot.snapshot_repository`; - const [formData] = useFormData({ watch: [searchableSnapshotPath, useRolloverPath] }); - const isRolloverEnabled = get(formData, useRolloverPath); + const [formData] = useFormData({ watch: searchableSnapshotPath }); const searchableSnapshotRepo = get(formData, searchableSnapshotPath); const isDisabledDueToLicense = !license.canUseSearchableSnapshot(); const isDisabledInColdDueToHotPhase = phase === 'cold' && isUsingSearchableSnapshotInHotPhase; - const isDisabledInColdDueToRollover = phase === 'cold' && !isRolloverEnabled; + const isDisabledInColdDueToRollover = phase === 'cold' && !isUsingRollover; const isDisabled = isDisabledDueToLicense || isDisabledInColdDueToHotPhase || isDisabledInColdDueToRollover; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx index d572e7a2ed341..77078e94d7e98 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx @@ -21,12 +21,12 @@ import { useConfigurationIssues } from '../../../form'; import { ActiveBadge, DescribedFormRow } from '../../'; import { - useRolloverPath, MinAgeInputField, ForcemergeField, SetPriorityInputField, DataTierAllocationField, ShrinkField, + ReadonlyField, } from '../shared_fields'; const i18nTexts = { @@ -46,13 +46,12 @@ const formFieldPaths = { export const WarmPhase: FunctionComponent = () => { const { policy } = useEditPolicyContext(); - const { isUsingSearchableSnapshotInHotPhase } = useConfigurationIssues(); + const { isUsingSearchableSnapshotInHotPhase, isUsingRollover } = useConfigurationIssues(); const [formData] = useFormData({ - watch: [useRolloverPath, formFieldPaths.enabled, formFieldPaths.warmPhaseOnRollover], + watch: [formFieldPaths.enabled, formFieldPaths.warmPhaseOnRollover], }); const enabled = get(formData, formFieldPaths.enabled); - const hotPhaseRolloverEnabled = get(formData, useRolloverPath); const warmPhaseOnRollover = get(formData, formFieldPaths.warmPhaseOnRollover); return ( @@ -98,7 +97,7 @@ export const WarmPhase: FunctionComponent = () => { <> {enabled && ( <> - {hotPhaseRolloverEnabled && ( + {isUsingRollover && ( { }} /> )} - {(!warmPhaseOnRollover || !hotPhaseRolloverEnabled) && ( + {(!warmPhaseOnRollover || !isUsingRollover) && ( <> @@ -173,6 +172,9 @@ export const WarmPhase: FunctionComponent = () => { {!isUsingSearchableSnapshotInHotPhase && } {!isUsingSearchableSnapshotInHotPhase && } + + + {/* Data tier allocation section */} (null as an const pathToHotPhaseSearchableSnapshot = 'phases.hot.actions.searchable_snapshot.snapshot_repository'; -const pathToHotForceMerge = 'phases.hot.actions.forcemerge.max_num_segments'; - export const ConfigurationIssuesProvider: FunctionComponent = ({ children }) => { const [formData] = useFormData({ - watch: [pathToHotPhaseSearchableSnapshot, pathToHotForceMerge], + watch: [pathToHotPhaseSearchableSnapshot, useRolloverPath, isUsingDefaultRolloverPath], }); + const isUsingDefaultRollover = get(formData, isUsingDefaultRolloverPath); + const rolloverSwitchEnabled = get(formData, useRolloverPath); return ( {children} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts index 04d4fbef9939e..160c3987f8898 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts @@ -10,7 +10,7 @@ import { SerializedPolicy } from '../../../../../common/types'; import { splitSizeAndUnits } from '../../../lib/policies'; -import { determineDataTierAllocationType } from '../../../lib'; +import { determineDataTierAllocationType, isUsingDefaultRollover } from '../../../lib'; import { FormInternal } from '../types'; @@ -22,13 +22,16 @@ export const deserializer = (policy: SerializedPolicy): FormInternal => { const _meta: FormInternal['_meta'] = { hot: { useRollover: Boolean(hot?.actions?.rollover), + isUsingDefaultRollover: isUsingDefaultRollover(policy), bestCompression: hot?.actions?.forcemerge?.index_codec === 'best_compression', + readonlyEnabled: Boolean(hot?.actions?.readonly), }, warm: { enabled: Boolean(warm), warmPhaseOnRollover: warm === undefined ? true : Boolean(warm.min_age === '0ms'), bestCompression: warm?.actions?.forcemerge?.index_codec === 'best_compression', dataTierAllocationType: determineDataTierAllocationType(warm?.actions), + readonlyEnabled: Boolean(warm?.actions?.readonly), }, cold: { enabled: Boolean(cold), diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts index 518a205b12303..b494e87b0bf6f 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer_and_serializer.test.ts @@ -44,6 +44,7 @@ const originalPolicy: SerializedPolicy = { index_codec: 'best_compression', max_num_segments: 22, }, + readonly: {}, set_priority: { priority: 1, }, @@ -63,6 +64,7 @@ const originalPolicy: SerializedPolicy = { some: 'value', }, }, + readonly: {}, set_priority: { priority: 10, }, @@ -170,6 +172,22 @@ describe('deserializer and serializer', () => { expect(result.phases.warm!.actions.forcemerge).toBeUndefined(); }); + it('removes the readonly action if it is disabled in hot', () => { + formInternal._meta.hot.readonlyEnabled = false; + + const result = serializer(formInternal); + + expect(result.phases.hot!.actions.readonly).toBeUndefined(); + }); + + it('removes the readonly action if it is disabled in warm', () => { + formInternal._meta.warm.readonlyEnabled = false; + + const result = serializer(formInternal); + + expect(result.phases.warm!.actions.readonly).toBeUndefined(); + }); + it('removes set priority if it is disabled in the form', () => { delete formInternal.phases.hot!.actions.set_priority; delete formInternal.phases.warm!.actions.set_priority; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index 73a868c392f32..ae2432971059c 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -38,6 +38,12 @@ export const schema: FormSchema = { defaultMessage: 'Enable rollover', }), }, + isUsingDefaultRollover: { + defaultValue: true, + label: i18n.translate('xpack.indexLifecycleMgmt.hotPhase.isUsingDefaultRollover', { + defaultMessage: 'Use recommended defaults', + }), + }, maxStorageSizeUnit: { defaultValue: 'gb', }, @@ -48,6 +54,10 @@ export const schema: FormSchema = { label: i18nTexts.editPolicy.bestCompressionFieldLabel, helpText: i18nTexts.editPolicy.bestCompressionFieldHelpText, }, + readonlyEnabled: { + defaultValue: false, + label: i18nTexts.editPolicy.readonlyEnabledFieldLabel, + }, }, warm: { enabled: { @@ -76,6 +86,10 @@ export const schema: FormSchema = { allocationNodeAttribute: { label: i18nTexts.editPolicy.allocationNodeAttributeFieldLabel, }, + readonlyEnabled: { + defaultValue: false, + label: i18nTexts.editPolicy.readonlyEnabledFieldLabel, + }, }, cold: { enabled: { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts index 91e175d49de25..2a7689b42554e 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer/serializer.ts @@ -6,11 +6,11 @@ import { produce } from 'immer'; -import { merge } from 'lodash'; +import { merge, cloneDeep } from 'lodash'; import { SerializedPolicy } from '../../../../../../common/types'; -import { defaultPolicy } from '../../../../constants'; +import { defaultPolicy, defaultRolloverAction } from '../../../../constants'; import { FormInternal } from '../../types'; @@ -42,7 +42,9 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( if (draft.phases.hot?.actions) { const hotPhaseActions = draft.phases.hot.actions; - if (hotPhaseActions.rollover && _meta.hot.useRollover) { + if (_meta.hot.isUsingDefaultRollover) { + hotPhaseActions.rollover = cloneDeep(defaultRolloverAction); + } else if (hotPhaseActions.rollover && _meta.hot.useRollover) { if (updatedPolicy.phases.hot!.actions.rollover?.max_age) { hotPhaseActions.rollover.max_age = `${hotPhaseActions.rollover.max_age}${_meta.hot.maxAgeUnit}`; } else { @@ -68,9 +70,16 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( if (_meta.hot.bestCompression && hotPhaseActions.forcemerge) { hotPhaseActions.forcemerge.index_codec = 'best_compression'; } + + if (_meta.hot.readonlyEnabled) { + hotPhaseActions.readonly = hotPhaseActions.readonly ?? {}; + } else { + delete hotPhaseActions.readonly; + } } else { delete hotPhaseActions.rollover; delete hotPhaseActions.forcemerge; + delete hotPhaseActions.readonly; } if (!updatedPolicy.phases.hot!.actions?.set_priority) { @@ -117,6 +126,12 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( warmPhase.actions.forcemerge!.index_codec = 'best_compression'; } + if (_meta.warm.readonlyEnabled) { + warmPhase.actions.readonly = warmPhase.actions.readonly ?? {}; + } else { + delete warmPhase.actions.readonly; + } + if (!updatedPolicy.phases.warm?.actions?.set_priority) { delete warmPhase.actions.set_priority; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 75bd3c3e217af..f30a40fdd2bb9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -31,6 +31,9 @@ export const i18nTexts = { forceMergeEnabledFieldLabel: i18n.translate('xpack.indexLifecycleMgmt.forcemerge.enableLabel', { defaultMessage: 'Force merge data', }), + readonlyEnabledFieldLabel: i18n.translate('xpack.indexLifecycleMgmt.readonlyFieldLabel', { + defaultMessage: 'Make index read only', + }), maxNumSegmentsFieldLabel: i18n.translate( 'xpack.indexLifecycleMgmt.forceMerge.numberOfSegmentsLabel', { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts index 7d512936290af..4dfd7503b9973 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts @@ -23,13 +23,16 @@ export interface ForcemergeFields { interface HotPhaseMetaFields extends ForcemergeFields { useRollover: boolean; + isUsingDefaultRollover: boolean; maxStorageSizeUnit?: string; maxAgeUnit?: string; + readonlyEnabled: boolean; } interface WarmPhaseMetaFields extends DataAllocationMetaFields, MinAgeField, ForcemergeFields { enabled: boolean; warmPhaseOnRollover: boolean; + readonlyEnabled: boolean; } interface ColdPhaseMetaFields extends DataAllocationMetaFields, MinAgeField { diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts index 9c92af30097a2..0fa1ddeee820d 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.helpers.ts @@ -19,7 +19,7 @@ export interface DataStreamsTabTestBed extends TestBed { goToDataStreamsList: () => void; clickEmptyPromptIndexTemplateLink: () => void; clickIncludeStatsSwitch: () => void; - clickIncludeManagedSwitch: () => void; + toggleViewFilterAt: (index: number) => void; clickReloadButton: () => void; clickNameAt: (index: number) => void; clickIndicesAt: (index: number) => void; @@ -82,9 +82,16 @@ export const setup = async (overridingDependencies: any = {}): Promise { - const { find } = testBed; - find('includeManagedSwitch').simulate('click'); + const toggleViewFilterAt = (index: number) => { + const { find, component } = testBed; + act(() => { + find('viewButton').simulate('click'); + }); + component.update(); + act(() => { + find('filterItem').at(index).simulate('click'); + }); + component.update(); }; const clickReloadButton = () => { @@ -197,7 +204,7 @@ export const setup = async (overridingDependencies: any = {}): Promise): DataSt privileges: { delete_index: true, }, + hidden: false, ...dataStream, }); diff --git a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts index 91502621d50c5..93899dece3308 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts +++ b/x-pack/plugins/index_management/__jest__/client_integration/home/data_streams_tab.test.ts @@ -19,6 +19,8 @@ import { createNonDataStreamIndex, } from './data_streams_tab.helpers'; +const nonBreakingSpace = ' '; + describe('Data Streams tab', () => { const { server, httpRequestsMockHelpers } = setupEnvironment(); let testBed: DataStreamsTabTestBed; @@ -82,6 +84,25 @@ describe('Data Streams tab', () => { // Assert against the text because the href won't be available, due to dependency upon our core mock. expect(findEmptyPromptIndexTemplateLink().text()).toBe('Fleet'); }); + + test('when hidden data streams are filtered by default, the table is rendered empty', async () => { + const hiddenDataStream = createDataStreamPayload({ + name: 'hidden-data-stream', + hidden: true, + }); + httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]); + + testBed = await setup({ + plugins: {}, + }); + + await act(async () => { + testBed.actions.goToDataStreamsList(); + }); + + testBed.component.update(); + expect(testBed.find('dataStreamTable').text()).toContain('No data streams found'); + }); }); describe('when there are data streams', () => { @@ -397,7 +418,6 @@ describe('Data Streams tab', () => { }); describe('managed data streams', () => { - const nonBreakingSpace = ' '; beforeEach(async () => { const managedDataStream = createDataStreamPayload({ name: 'managed-data-stream', @@ -429,8 +449,8 @@ describe('Data Streams tab', () => { ]); }); - test('turning off "Include managed" switch hides managed data streams', async () => { - const { exists, actions, component, table } = testBed; + test('turning off "managed" filter hides managed data streams', async () => { + const { actions, table } = testBed; let { tableCellsValues } = table.getMetaData('dataStreamTable'); expect(tableCellsValues).toEqual([ @@ -438,15 +458,40 @@ describe('Data Streams tab', () => { ['', 'non-managed-data-stream', 'green', '1', 'Delete'], ]); - expect(exists('includeManagedSwitch')).toBe(true); + actions.toggleViewFilterAt(0); + + ({ tableCellsValues } = table.getMetaData('dataStreamTable')); + expect(tableCellsValues).toEqual([['', 'non-managed-data-stream', 'green', '1', 'Delete']]); + }); + }); + + describe('hidden data streams', () => { + beforeEach(async () => { + const hiddenDataStream = createDataStreamPayload({ + name: 'hidden-data-stream', + hidden: true, + }); + httpRequestsMockHelpers.setLoadDataStreamsResponse([hiddenDataStream]); + testBed = await setup({ + history: createMemoryHistory(), + }); await act(async () => { - actions.clickIncludeManagedSwitch(); + testBed.actions.goToDataStreamsList(); }); - component.update(); + testBed.component.update(); + }); - ({ tableCellsValues } = table.getMetaData('dataStreamTable')); - expect(tableCellsValues).toEqual([['', 'non-managed-data-stream', 'green', '1', 'Delete']]); + test('show hidden data streams when filter is toggled', () => { + const { table, actions } = testBed; + + actions.toggleViewFilterAt(1); + + const { tableCellsValues } = table.getMetaData('dataStreamTable'); + + expect(tableCellsValues).toEqual([ + ['', `hidden-data-stream${nonBreakingSpace}Hidden`, 'green', '1', 'Delete'], + ]); }); }); diff --git a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts index fe7db99c98db1..333cb4b97f2aa 100644 --- a/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts +++ b/x-pack/plugins/index_management/common/lib/data_stream_serialization.ts @@ -19,6 +19,7 @@ export function deserializeDataStream(dataStreamFromEs: DataStreamFromEs): DataS maximum_timestamp: maxTimeStamp, _meta, privileges, + hidden, } = dataStreamFromEs; return { @@ -39,6 +40,7 @@ export function deserializeDataStream(dataStreamFromEs: DataStreamFromEs): DataS maxTimeStamp, _meta, privileges, + hidden, }; } diff --git a/x-pack/plugins/index_management/common/types/data_streams.ts b/x-pack/plugins/index_management/common/types/data_streams.ts index fdfe6278eb985..fca10f85ab63c 100644 --- a/x-pack/plugins/index_management/common/types/data_streams.ts +++ b/x-pack/plugins/index_management/common/types/data_streams.ts @@ -38,6 +38,7 @@ export interface DataStreamFromEs { store_size?: string; maximum_timestamp?: number; privileges: PrivilegesFromEs; + hidden: boolean; } export interface DataStreamIndexFromEs { @@ -59,6 +60,7 @@ export interface DataStream { maxTimeStamp?: number; _meta?: Meta; privileges: Privileges; + hidden: boolean; } export interface DataStreamIndex { diff --git a/x-pack/plugins/index_management/public/application/lib/data_streams.tsx b/x-pack/plugins/index_management/public/application/lib/data_streams.tsx index ca5297e399339..93791f8a58224 100644 --- a/x-pack/plugins/index_management/public/application/lib/data_streams.tsx +++ b/x-pack/plugins/index_management/public/application/lib/data_streams.tsx @@ -6,10 +6,34 @@ import { DataStream } from '../../../common'; -export const isManagedByIngestManager = (dataStream: DataStream): boolean => { +export const isFleetManaged = (dataStream: DataStream): boolean => { + // TODO check if the wording will change to 'fleet' return Boolean(dataStream._meta?.managed && dataStream._meta?.managed_by === 'ingest-manager'); }; -export const filterDataStreams = (dataStreams: DataStream[]): DataStream[] => { - return dataStreams.filter((dataStream: DataStream) => !isManagedByIngestManager(dataStream)); +export const filterDataStreams = ( + dataStreams: DataStream[], + visibleTypes: string[] +): DataStream[] => { + return dataStreams.filter((dataStream: DataStream) => { + // include all data streams that are neither hidden nor managed + if (!dataStream.hidden && !isFleetManaged(dataStream)) { + return true; + } + if (dataStream.hidden && visibleTypes.includes('hidden')) { + return true; + } + return isFleetManaged(dataStream) && visibleTypes.includes('managed'); + }); +}; + +export const isSelectedDataStreamHidden = ( + dataStreams: DataStream[], + selectedDataStreamName?: string +): boolean => { + return ( + !!selectedDataStreamName && + !!dataStreams.find((dataStream: DataStream) => dataStream.name === selectedDataStreamName) + ?.hidden + ); }; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/filter_list_button.tsx b/x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx similarity index 100% rename from x-pack/plugins/index_management/public/application/sections/home/template_list/components/filter_list_button.tsx rename to x-pack/plugins/index_management/public/application/sections/home/components/filter_list_button.tsx diff --git a/x-pack/plugins/index_management/public/application/sections/home/components/index.ts b/x-pack/plugins/index_management/public/application/sections/home/components/index.ts new file mode 100644 index 0000000000000..3df506583b65a --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/components/index.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 { FilterListButton, Filters } from './filter_list_button'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_badges.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_badges.tsx new file mode 100644 index 0000000000000..e86dfe7f28585 --- /dev/null +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_badges.tsx @@ -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 React from 'react'; +import { EuiBadge, EuiBadgeGroup } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { DataStream } from '../../../../../common'; +import { isFleetManaged } from '../../../lib/data_streams'; + +interface Props { + dataStream: DataStream; +} + +export const DataStreamsBadges: React.FunctionComponent = ({ dataStream }) => { + const badges = []; + if (isFleetManaged(dataStream)) { + badges.push( + + + + ); + } + if (dataStream.hidden) { + badges.push( + + + + ); + } + return badges.length > 0 ? ( + <> +   + {badges} + + ) : null; +}; diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx index ec47b2c062aa9..33fd1b3f18716 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_detail_panel/data_stream_detail_panel.tsx @@ -32,6 +32,7 @@ import { useUrlGenerator } from '../../../../services/use_url_generator'; import { getIndexListUri, getTemplateDetailsLink } from '../../../../services/routing'; import { ILM_PAGES_POLICY_EDIT, ILM_URL_GENERATOR_ID } from '../../../../constants'; import { useAppContext } from '../../../../app_context'; +import { DataStreamsBadges } from '../data_stream_badges'; interface DetailsListProps { details: Array<{ @@ -269,6 +270,7 @@ export const DataStreamDetailPanel: React.FunctionComponent = ({

{dataStreamName} + {dataStream && }

diff --git a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx index f43b9799082a0..64d874c76afb3 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/data_stream_list/data_stream_list.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { RouteComponentProps } from 'react-router-dom'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -32,8 +32,10 @@ import { documentationService } from '../../../services/documentation'; import { Section } from '../home'; import { DataStreamTable } from './data_stream_table'; import { DataStreamDetailPanel } from './data_stream_detail_panel'; -import { filterDataStreams } from '../../../lib/data_streams'; +import { filterDataStreams, isSelectedDataStreamHidden } from '../../../lib/data_streams'; +import { FilterListButton, Filters } from '../components'; +export type DataStreamFilterName = 'managed' | 'hidden'; interface MatchParams { dataStreamName?: string; } @@ -45,7 +47,7 @@ export const DataStreamList: React.FunctionComponent { - const { isDeepLink } = extractQueryParams(search); + const { isDeepLink, includeHidden } = extractQueryParams(search); const decodedDataStreamName = attemptToURIDecode(dataStreamName); const { @@ -54,11 +56,111 @@ export const DataStreamList: React.FunctionComponent>({ + managed: { + name: i18n.translate('xpack.idxMgmt.dataStreamList.viewManagedLabel', { + defaultMessage: 'Fleet-managed data streams', + }), + checked: 'on', + }, + hidden: { + name: i18n.translate('xpack.idxMgmt.dataStreamList.viewHiddenLabel', { + defaultMessage: 'Hidden data streams', + }), + checked: includeHidden ? 'on' : 'off', + }, + }); + + const activateHiddenFilter = (shouldBeActive: boolean) => { + if (shouldBeActive && filters.hidden.checked === 'off') { + setFilters({ + ...filters, + hidden: { + ...filters.hidden, + checked: 'on', + }, + }); + } + }; + + const filteredDataStreams = useMemo(() => { + if (!dataStreams) { + // If dataStreams are not fetched, return empty array. + return []; + } + + const visibleTypes = Object.entries(filters) + .filter(([name, _filter]) => _filter.checked === 'on') + .map(([name]) => name); + + return filterDataStreams(dataStreams, visibleTypes); + }, [dataStreams, filters]); + + const renderHeader = () => { + return ( + + + + + {i18n.translate('xpack.idxMgmt.dataStreamListDescription.learnMoreLinkText', { + defaultMessage: 'Learn more.', + })} + + ), + }} + /> + + + + + + + setIsIncludeStatsChecked(e.target.checked)} + data-test-subj="includeStatsSwitch" + /> + + + + + + + + + filters={filters} onChange={setFilters} /> + + + ); + }; + let content; if (isLoading) { @@ -150,94 +252,10 @@ export const DataStreamList: React.FunctionComponent ); } else if (Array.isArray(dataStreams) && dataStreams.length > 0) { - const filteredDataStreams = isIncludeManagedChecked - ? dataStreams - : filterDataStreams(dataStreams); + activateHiddenFilter(isSelectedDataStreamHidden(dataStreams, decodedDataStreamName)); content = ( <> - - - - - {i18n.translate('xpack.idxMgmt.dataStreamListDescription.learnMoreLinkText', { - defaultMessage: 'Learn more.', - })} - - ), - }} - /> - - - - - - - setIsIncludeStatsChecked(e.target.checked)} - data-test-subj="includeStatsSwitch" - /> - - - - - - - - - - - setIsIncludeManagedChecked(e.target.checked)} - data-test-subj="includeManagedSwitch" - /> - - - - - - - - - + {renderHeader()} = ({ > {name} - {isManagedByIngestManager(dataStream) ? ( - -   - - - - - - - ) : null} + ); }, diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts index 3954ce04ca0b5..cccdcaf9389bd 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/components/index.ts @@ -4,6 +4,4 @@ * you may not use this file except in compliance with the Elastic License. */ -export * from './filter_list_button'; - export * from './template_type_indicator'; diff --git a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx index 266003c5f8949..6edabbb2867a2 100644 --- a/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx +++ b/x-pack/plugins/index_management/public/application/sections/home/template_list/template_list.tsx @@ -36,7 +36,7 @@ import { getIsLegacyFromQueryParams } from '../../../lib/index_templates'; import { TemplateTable } from './template_table'; import { TemplateDetails } from './template_details'; import { LegacyTemplateTable } from './legacy_templates/template_table'; -import { FilterListButton, Filters } from './components'; +import { FilterListButton, Filters } from '../components'; import { attemptToURIDecode } from '../../../../shared_imports'; type FilterName = 'managed' | 'cloudManaged' | 'system'; diff --git a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts index d19383d892cbd..4124d8e897b5b 100644 --- a/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts +++ b/x-pack/plugins/index_management/server/routes/api/data_streams/register_get_route.ts @@ -65,12 +65,24 @@ const enhanceDataStreams = ({ }); }; +const getDataStreams = (client: ElasticsearchClient, name = '*') => { + // TODO update when elasticsearch client has update requestParams for 'indices.getDataStream' + return client.transport.request({ + path: `/_data_stream/${encodeURIComponent(name)}`, + method: 'GET', + querystring: { + expand_wildcards: 'all', + }, + }); +}; + const getDataStreamsStats = (client: ElasticsearchClient, name = '*') => { return client.transport.request({ path: `/_data_stream/${encodeURIComponent(name)}/_stats`, method: 'GET', querystring: { human: true, + expand_wildcards: 'all', }, }); }; @@ -107,7 +119,7 @@ export function registerGetAllRoute({ try { let { body: { data_streams: dataStreams }, - } = await asCurrentUser.indices.getDataStream(); + } = await getDataStreams(asCurrentUser); let dataStreamsStats; let dataStreamsPrivileges; @@ -165,7 +177,7 @@ export function registerGetOneRoute({ body: { data_streams: dataStreamsStats }, }, ] = await Promise.all([ - asCurrentUser.indices.getDataStream({ name }), + getDataStreams(asCurrentUser, name), getDataStreamsStats(asCurrentUser, name), ]); diff --git a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap b/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap index 9c7bdc3397f9c..d340d002b242b 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap +++ b/x-pack/plugins/lens/public/datatable_visualization/__snapshots__/expression.test.tsx.snap @@ -1,5 +1,60 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`datatable_expression DatatableComponent it renders actions column when there are row actions 1`] = ` + + + +`; + exports[`datatable_expression DatatableComponent it renders the title and value 1`] = ` { ).toMatchSnapshot(); }); + test('it renders actions column when there are row actions', () => { + const { data, args } = sampleArgs(); + + expect( + shallow( + x as IFieldFormat} + onClickValue={onClickValue} + getType={jest.fn()} + onRowContextMenuClick={() => undefined} + rowHasRowClickTriggerActions={[true, true, true]} + /> + ) + ).toMatchSnapshot(); + }); + test('it invokes executeTriggerActions with correct context on click on top value', () => { const { args, data } = sampleArgs(); diff --git a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx index 6502e07697816..f1eaab908717a 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/expression.tsx +++ b/x-pack/plugins/lens/public/datatable_visualization/expression.tsx @@ -10,13 +10,22 @@ import React, { useMemo } from 'react'; import ReactDOM from 'react-dom'; import { i18n } from '@kbn/i18n'; import { I18nProvider } from '@kbn/i18n/react'; -import { EuiBasicTable, EuiFlexGroup, EuiButtonIcon, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiButtonIcon, + EuiFlexItem, + EuiToolTip, + EuiBasicTableColumn, + EuiTableActionsColumnType, +} from '@elastic/eui'; import { IAggType } from 'src/plugins/data/public'; import { FormatFactory, ILensInterpreterRenderHandlers, LensFilterEvent, LensMultiTable, + LensTableRowContextMenuEvent, } from '../types'; import { ExpressionFunctionDefinition, @@ -45,7 +54,14 @@ export interface DatatableProps { type DatatableRenderProps = DatatableProps & { formatFactory: FormatFactory; onClickValue: (data: LensFilterEvent['data']) => void; + onRowContextMenuClick?: (data: LensTableRowContextMenuEvent['data']) => void; getType: (name: string) => IAggType; + + /** + * A boolean for each table row, which is true if the row active + * ROW_CLICK_TRIGGER actions attached to it, otherwise false. + */ + rowHasRowClickTriggerActions?: boolean[]; }; export interface DatatableRender { @@ -143,13 +159,47 @@ export const getDatatableRenderer = (dependencies: { const onClickValue = (data: LensFilterEvent['data']) => { handlers.event({ name: 'filter', data }); }; + const onRowContextMenuClick = (data: LensTableRowContextMenuEvent['data']) => { + handlers.event({ name: 'tableRowContextMenuClick', data }); + }; + const { hasCompatibleActions } = handlers; + + // An entry for each table row, whether it has any actions attached to + // ROW_CLICK_TRIGGER trigger. + let rowHasRowClickTriggerActions: boolean[] = []; + if (hasCompatibleActions) { + const table = Object.values(config.data.tables)[0]; + if (!!table) { + rowHasRowClickTriggerActions = await Promise.all( + table.rows.map(async (row, rowIndex) => { + try { + const hasActions = await hasCompatibleActions({ + name: 'tableRowContextMenuClick', + data: { + rowIndex, + table, + columns: config.args.columns.columnIds, + }, + }); + + return hasActions; + } catch { + return false; + } + }) + ); + } + } + ReactDOM.render( , domNode, @@ -169,7 +219,7 @@ export function DatatableComponent(props: DatatableRenderProps) { formatters[column.id] = props.formatFactory(column.meta?.params); }); - const { onClickValue } = props; + const { onClickValue, onRowContextMenuClick } = props; const handleFilterClick = useMemo( () => (field: string, value: unknown, colIndex: number, negate: boolean = false) => { const col = firstTable.columns[colIndex]; @@ -214,6 +264,124 @@ export function DatatableComponent(props: DatatableRenderProps) { return ; } + const tableColumns: Array< + EuiBasicTableColumn<{ rowIndex: number; [key: string]: unknown }> + > = props.args.columns.columnIds + .map((field) => { + const col = firstTable.columns.find((c) => c.id === field); + const filterable = bucketColumns.includes(field); + const colIndex = firstTable.columns.findIndex((c) => c.id === field); + return { + field, + name: (col && col.name) || '', + render: (value: unknown) => { + const formattedValue = formatters[field]?.convert(value); + const fieldName = col?.meta?.field; + + if (filterable) { + return ( + + {formattedValue} + + + + handleFilterClick(field, value, colIndex)} + /> + + + + handleFilterClick(field, value, colIndex, true)} + /> + + + + + + ); + } + return {formattedValue}; + }, + }; + }) + .filter(({ field }) => !!field); + + if (!!props.rowHasRowClickTriggerActions && !!onRowContextMenuClick) { + const hasAtLeastOneRowClickAction = props.rowHasRowClickTriggerActions.find((x) => x); + if (hasAtLeastOneRowClickAction) { + const actions: EuiTableActionsColumnType<{ rowIndex: number; [key: string]: unknown }> = { + name: i18n.translate('xpack.lens.datatable.actionsColumnName', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate('xpack.lens.tableRowMore', { + defaultMessage: 'More', + }), + description: i18n.translate('xpack.lens.tableRowMoreDescription', { + defaultMessage: 'Table row context menu', + }), + type: 'icon', + icon: ({ rowIndex }: { rowIndex: number }) => { + if ( + !!props.rowHasRowClickTriggerActions && + !props.rowHasRowClickTriggerActions[rowIndex] + ) + return 'empty'; + return 'boxesVertical'; + }, + onClick: ({ rowIndex }) => { + onRowContextMenuClick({ + rowIndex, + table: firstTable, + columns: props.args.columns.columnIds, + }); + }, + }, + ], + }; + tableColumns.push(actions); + } + } + return ( { - const col = firstTable.columns.find((c) => c.id === field); - const filterable = bucketColumns.includes(field); - const colIndex = firstTable.columns.findIndex((c) => c.id === field); - return { - field, - name: (col && col.name) || '', - render: (value: unknown) => { - const formattedValue = formatters[field]?.convert(value); - const fieldName = col?.meta?.field; - - if (filterable) { - return ( - - {formattedValue} - - - - handleFilterClick(field, value, colIndex)} - /> - - - - handleFilterClick(field, value, colIndex, true)} - /> - - - - - - ); - } - return {formattedValue}; - }, - }; - }) - .filter(({ field }) => !!field)} - items={firstTable ? firstTable.rows : []} + columns={tableColumns} + items={firstTable ? firstTable.rows.map((row, rowIndex) => ({ ...row, rowIndex })) : []} /> ); diff --git a/x-pack/plugins/lens/public/datatable_visualization/index.ts b/x-pack/plugins/lens/public/datatable_visualization/index.ts index 5d9be46db7fb5..9c7d7ae1f2d43 100644 --- a/x-pack/plugins/lens/public/datatable_visualization/index.ts +++ b/x-pack/plugins/lens/public/datatable_visualization/index.ts @@ -7,11 +7,9 @@ import { CoreSetup } from 'kibana/public'; import { ExpressionsSetup } from '../../../../../src/plugins/expressions/public'; import { EditorFrameSetup, FormatFactory } from '../types'; -import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; import { DataPublicPluginStart } from '../../../../../src/plugins/data/public'; interface DatatableVisualizationPluginStartPlugins { - uiActions: UiActionsStart; data: DataPublicPluginStart; } export interface DatatableVisualizationPluginSetupPlugins { @@ -34,6 +32,7 @@ export class DatatableVisualization { getDatatableRenderer, datatableVisualization, } = await import('../async_services'); + expressions.registerFunction(() => datatableColumns); expressions.registerFunction(() => datatable); expressions.registerRenderer(() => diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index 54517e4ee8c84..175c573d3be3a 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -25,7 +25,7 @@ import { dataPluginMock } from '../../../../../../src/plugins/data/public/mocks' import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/plugins/visualizations/public/embeddable'; import { coreMock, httpServiceMock } from '../../../../../../src/core/public/mocks'; import { IBasePath } from '../../../../../../src/core/public'; -import { AttributeService } from '../../../../../../src/plugins/embeddable/public'; +import { AttributeService, ViewMode } from '../../../../../../src/plugins/embeddable/public'; import { LensAttributeService } from '../../lens_attribute_service'; import { OnSaveProps } from '../../../../../../src/plugins/saved_objects/public/save_modal'; import { act } from 'react-dom/test-utils'; @@ -221,6 +221,74 @@ describe('embeddable', () => { expect(expressionRenderer).toHaveBeenCalledTimes(2); }); + it('should re-render when dashboard view/edit mode changes', async () => { + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), + }, + { id: '123' } as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput); + embeddable.render(mountpoint); + + expect(expressionRenderer).toHaveBeenCalledTimes(1); + + embeddable.updateInput({ + viewMode: ViewMode.VIEW, + }); + + expect(expressionRenderer).toHaveBeenCalledTimes(2); + }); + + it('should re-render when dynamic actions input changes', async () => { + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), + }, + { id: '123' } as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput); + embeddable.render(mountpoint); + + expect(expressionRenderer).toHaveBeenCalledTimes(1); + + embeddable.updateInput({ + enhancements: { + dynamicActions: {}, + }, + }); + + expect(expressionRenderer).toHaveBeenCalledTimes(2); + }); + it('should pass context to embeddable', async () => { const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; const query: Query = { language: 'kquery', query: '' }; @@ -396,6 +464,37 @@ describe('embeddable', () => { ); }); + it('should execute trigger on row click event from expression renderer', async () => { + const embeddable = new Embeddable( + { + timefilter: dataPluginMock.createSetupContract().query.timefilter.timefilter, + attributeService, + expressionRenderer, + basePath, + indexPatternService: {} as IndexPatternsContract, + editable: true, + getTrigger, + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), + }, + { id: '123' } as LensEmbeddableInput + ); + await embeddable.initializeSavedVis({ id: '123' } as LensEmbeddableInput); + embeddable.render(mountpoint); + + const onEvent = expressionRenderer.mock.calls[0][0].onEvent!; + + onEvent({ name: 'tableRowContextMenuClick', data: {} }); + + expect(getTrigger).toHaveBeenCalledWith(VIS_EVENT_TO_TRIGGER.tableRowContextMenuClick); + }); + it('should not re-render if only change is in disabled filter', async () => { const timeRange: TimeRange = { from: 'now-15d', to: 'now' }; const query: Query = { language: 'kquery', query: '' }; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index e7d3e1a4bfa5b..6c86ae5cff2c8 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -21,6 +21,8 @@ import { PaletteOutput } from 'src/plugins/charts/public'; import { Subscription } from 'rxjs'; import { toExpression, Ast } from '@kbn/interpreter/common'; import { RenderMode } from 'src/plugins/expressions'; +import { map, distinctUntilChanged, skip } from 'rxjs/operators'; +import isEqual from 'fast-deep-equal'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -38,7 +40,11 @@ import { import { Document, injectFilterReferences } from '../../persistence'; import { ExpressionWrapper } from './expression_wrapper'; import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; -import { isLensBrushEvent, isLensFilterEvent } from '../../types'; +import { + isLensBrushEvent, + isLensFilterEvent, + isLensTableRowContextMenuClickEvent, +} from '../../types'; import { IndexPatternsContract } from '../../../../../../src/plugins/data/public'; import { getEditPath, DOC_TYPE } from '../../../common'; @@ -71,6 +77,7 @@ export interface LensEmbeddableDeps { timefilter: TimefilterContract; basePath: IBasePath; getTrigger?: UiActionsStart['getTrigger'] | undefined; + getTriggerCompatibleActions?: UiActionsStart['getTriggerCompatibleActions']; } export class Embeddable @@ -117,6 +124,36 @@ export class Embeddable this.autoRefreshFetchSubscription = deps.timefilter .getAutoRefreshFetch$() .subscribe(this.reload.bind(this)); + + const input$ = this.getInput$(); + + // Lens embeddable does not re-render when embeddable input changes in + // general, to improve performance. This line makes sure the Lens embeddable + // re-renders when anything in ".dynamicActions" (e.g. drilldowns) changes. + input$ + .pipe( + map((input) => input.enhancements?.dynamicActions), + distinctUntilChanged((a, b) => isEqual(a, b)), + skip(1) + ) + .subscribe((input) => { + this.reload(); + }); + + // Lens embeddable does not re-render when embeddable input changes in + // general, to improve performance. This line makes sure the Lens embeddable + // re-renders when dashboard view mode switches between "view/edit". This is + // needed to see the changes to ".dynamicActions" (e.g. drilldowns) when + // dashboard's mode is toggled. + input$ + .pipe( + map((input) => input.viewMode), + distinctUntilChanged(), + skip(1) + ) + .subscribe((input) => { + this.reload(); + }); } public supportedTriggers() { @@ -127,6 +164,7 @@ export class Embeddable case 'lnsXY': return [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.brush]; case 'lnsDatatable': + return [VIS_EVENT_TO_TRIGGER.filter, VIS_EVENT_TO_TRIGGER.tableRowContextMenuClick]; case 'lnsPie': return [VIS_EVENT_TO_TRIGGER.filter]; case 'lnsMetric': @@ -217,11 +255,31 @@ export class Embeddable handleEvent={this.handleEvent} onData$={this.updateActiveData} renderMode={input.renderMode} + hasCompatibleActions={this.hasCompatibleActions} />, domNode ); } + private readonly hasCompatibleActions = async ( + event: ExpressionRendererEvent + ): Promise => { + if (isLensTableRowContextMenuClickEvent(event)) { + const { getTriggerCompatibleActions } = this.deps; + if (!getTriggerCompatibleActions) { + return false; + } + const actions = await getTriggerCompatibleActions(VIS_EVENT_TO_TRIGGER[event.name], { + data: event.data, + embeddable: this, + }); + + return actions.length > 0; + } + + return false; + }; + /** * Combines the embeddable context with the saved object context, and replaces * any references to index patterns @@ -264,6 +322,16 @@ export class Embeddable embeddable: this, }); } + + if (isLensTableRowContextMenuClickEvent(event)) { + this.deps.getTrigger(VIS_EVENT_TO_TRIGGER[event.name]).exec( + { + data: event.data, + embeddable: this, + }, + true + ); + } }; async reload() { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts index 65e9c22d24eaf..175ec0dbcfd54 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts @@ -94,6 +94,7 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { editable: await this.isEditable(), basePath: coreHttp.basePath, getTrigger: uiActions?.getTrigger, + getTriggerCompatibleActions: uiActions?.getTriggerCompatibleActions, documentToExpression, }, input, diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx index 4645420898314..2fc1cfee82fd3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/expression_wrapper.tsx @@ -11,6 +11,7 @@ import { EuiFlexGroup, EuiFlexItem, EuiText, EuiIcon } from '@elastic/eui'; import { ExpressionRendererEvent, ReactExpressionRendererType, + ReactExpressionRendererProps, } from 'src/plugins/expressions/public'; import { ExecutionContextSearch } from 'src/plugins/data/public'; import { RenderMode } from 'src/plugins/expressions'; @@ -26,6 +27,7 @@ export interface ExpressionWrapperProps { handleEvent: (event: ExpressionRendererEvent) => void; onData$: (data: unknown, inspectorAdapters?: LensInspectorAdapters | undefined) => void; renderMode?: RenderMode; + hasCompatibleActions?: ReactExpressionRendererProps['hasCompatibleActions']; } export function ExpressionWrapper({ @@ -37,6 +39,7 @@ export function ExpressionWrapper({ searchSessionId, onData$, renderMode, + hasCompatibleActions, }: ExpressionWrapperProps) { return ( @@ -80,6 +83,7 @@ export function ExpressionWrapper({
)} onEvent={handleEvent} + hasCompatibleActions={hasCompatibleActions} />
)} diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx index ddcb5633b376f..de7a826485831 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/operations/definitions/shared_components/label_input.tsx @@ -47,7 +47,7 @@ export const LabelInput = ({ inputRef.current = node; } }} - onKeyDown={({ key }: React.KeyboardEvent) => { + onKeyUp={({ key }: React.KeyboardEvent) => { if (keys.ENTER === key && onSubmit) { onSubmit(); } diff --git a/x-pack/plugins/lens/public/pie_visualization/visualization.scss b/x-pack/plugins/lens/public/pie_visualization/visualization.scss index d9ff75d849708..a8890208596b6 100644 --- a/x-pack/plugins/lens/public/pie_visualization/visualization.scss +++ b/x-pack/plugins/lens/public/pie_visualization/visualization.scss @@ -1,4 +1,7 @@ .lnsPieExpression__container { height: 100%; width: 100%; + // the FocusTrap is adding extra divs which are making the visualization redraw twice + // with a visible glitch. This make the chart library resilient to this extra reflow + overflow-x: hidden; } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index b0da6cf2e8434..23d026bf2b443 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -8,6 +8,7 @@ import { IconType } from '@elastic/eui/src/components/icon/icon'; import { CoreSetup } from 'kibana/public'; import { PaletteOutput, PaletteRegistry } from 'src/plugins/charts/public'; import { SavedObjectReference } from 'kibana/public'; +import { ROW_CLICK_TRIGGER } from '../../../../src/plugins/ui_actions/public'; import { ExpressionAstExpression, ExpressionRendererEvent, @@ -614,11 +615,17 @@ export interface LensFilterEvent { name: 'filter'; data: TriggerContext['data']; } + export interface LensBrushEvent { name: 'brush'; data: TriggerContext['data']; } +export interface LensTableRowContextMenuEvent { + name: 'tableRowContextMenuClick'; + data: TriggerContext['data']; +} + export function isLensFilterEvent(event: ExpressionRendererEvent): event is LensFilterEvent { return event.name === 'filter'; } @@ -627,11 +634,17 @@ export function isLensBrushEvent(event: ExpressionRendererEvent): event is LensB return event.name === 'brush'; } +export function isLensTableRowContextMenuClickEvent( + event: ExpressionRendererEvent +): event is LensBrushEvent { + return event.name === 'tableRowContextMenuClick'; +} + /** * Expression renderer handlers specifically for lens renderers. This is a narrowed down * version of the general render handlers, specifying supported event types. If this type is * used, dispatched events will be handled correctly. */ export interface ILensInterpreterRenderHandlers extends IInterpreterRenderHandlers { - event: (event: LensFilterEvent | LensBrushEvent) => void; + event: (event: LensFilterEvent | LensBrushEvent | LensTableRowContextMenuEvent) => void; } diff --git a/x-pack/plugins/lens/public/xy_visualization/expression.scss b/x-pack/plugins/lens/public/xy_visualization/expression.scss index 579f66f99b9fb..68f5e9863d2bb 100644 --- a/x-pack/plugins/lens/public/xy_visualization/expression.scss +++ b/x-pack/plugins/lens/public/xy_visualization/expression.scss @@ -1,6 +1,9 @@ .lnsXyExpression__container { height: 100%; width: 100%; + // the FocusTrap is adding extra divs which are making the visualization redraw twice + // with a visible glitch. This make the chart library resilient to this extra reflow + overflow-x: hidden; } .lnsChart__empty { diff --git a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx index dc6ce285754fc..cc4df1f0f9315 100644 --- a/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx +++ b/x-pack/plugins/lens/public/xy_visualization/xy_config_panel.tsx @@ -154,6 +154,28 @@ export function LayerContextMenu(props: VisualizationLayerWidgetProps) { ); } +function getValueLabelDisableReason({ + isAreaPercentage, + isHistogramSeries, +}: { + isAreaPercentage: boolean; + isHistogramSeries: boolean; +}): string { + if (isHistogramSeries) { + return i18n.translate('xpack.lens.xyChart.valuesHistogramDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on histograms.', + }); + } + if (isAreaPercentage) { + return i18n.translate('xpack.lens.xyChart.valuesPercentageDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on percentage area charts.', + }); + } + return i18n.translate('xpack.lens.xyChart.valuesStackedDisabledHelpText', { + defaultMessage: 'This setting cannot be changed on stacked or percentage bar charts', + }); +} + export function XyToolbar(props: VisualizationToolbarProps) { const { state, setState, frame } = props; @@ -246,20 +268,17 @@ export function XyToolbar(props: VisualizationToolbarProps) { const isValueLabelsEnabled = !hasNonBarSeries && hasBarNotStacked && !isHistogramSeries; const isFittingEnabled = hasNonBarSeries; + const valueLabelsDisabledReason = getValueLabelDisableReason({ + isAreaPercentage, + isHistogramSeries, + }); + return ( { + test('should return null when values is not defined', () => { + expect(percentilesValuesToFieldMeta(undefined)).toBeNull(); + expect(percentilesValuesToFieldMeta({})).toBeNull(); + }); + + test('should convert values to percentiles field meta', () => { + expect(percentilesValuesToFieldMeta(undefined)).toBeNull(); + expect( + percentilesValuesToFieldMeta({ + values: { + '25.0': 375.0, + '50.0': 400.0, + '75.0': 550.0, + }, + }) + ).toEqual([ + { percentile: '25.0', value: 375.0 }, + { percentile: '50.0', value: 400.0 }, + { percentile: '75.0', value: 550.0 }, + ]); + }); + + test('should remove duplicated percentile percentilesValuesToFieldMeta', () => { + expect(percentilesValuesToFieldMeta(undefined)).toBeNull(); + expect( + percentilesValuesToFieldMeta({ + values: { + '25.0': 375.0, + '50.0': 375.0, + '75.0': 550.0, + }, + }) + ).toEqual([ + { percentile: '25.0', value: 375.0 }, + { percentile: '75.0', value: 550.0 }, + ]); + }); +}); diff --git a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx index 2f2ddd7d539cf..882247e375ddc 100644 --- a/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx +++ b/x-pack/plugins/maps/public/classes/styles/vector/properties/dynamic_style_property.tsx @@ -28,6 +28,7 @@ import { import { CategoryFieldMeta, FieldMetaOptions, + PercentilesFieldMeta, RangeFieldMeta, StyleMetaData, } from '../../../../../common/descriptor_types'; @@ -144,15 +145,8 @@ export class DynamicStyleProperty const styleMetaData = styleMetaDataRequest.getData() as StyleMetaData; const percentiles = styleMetaData[`${this._field.getRootName()}_percentiles`] as | undefined - | { values?: { [key: string]: number } }; - return percentiles !== undefined && percentiles.values !== undefined - ? Object.keys(percentiles.values).map((key) => { - return { - percentile: key, - value: percentiles.values![key], - }; - }) - : null; + | PercentilesValues; + return percentilesValuesToFieldMeta(percentiles); } getCategoryFieldMeta() { @@ -499,3 +493,21 @@ export function getNumericalMbFeatureStateValue(value: RawValue) { const valueAsFloat = parseFloat(value); return isNaN(valueAsFloat) ? null : valueAsFloat; } + +interface PercentilesValues { + values?: { [key: string]: number }; +} +export function percentilesValuesToFieldMeta( + percentiles?: PercentilesValues | undefined +): PercentilesFieldMeta | null { + if (percentiles === undefined || percentiles.values === undefined) { + return null; + } + const percentilesFieldMeta = Object.keys(percentiles.values).map((key) => { + return { + percentile: key, + value: percentiles.values![key], + }; + }); + return _.uniqBy(percentilesFieldMeta, 'value'); +} diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts index 4ca708e9d2832..7d471d528595e 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apm_info.ts @@ -21,17 +21,23 @@ export function handleResponse(response: ElasticsearchResponse, apmUuid: string) return {}; } - const firstStats = response.hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats; - const stats = response.hits.hits[0]._source.beats_stats; + const firstHit = response.hits.hits[0]; - if (!firstStats || !stats) { - return {}; + let firstStats = null; + const stats = firstHit._source.beats_stats ?? {}; + + if ( + firstHit.inner_hits?.first_hit?.hits?.hits && + firstHit.inner_hits?.first_hit?.hits?.hits.length > 0 && + firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats + ) { + firstStats = firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats; } - const eventsTotalFirst = firstStats.metrics?.libbeat?.pipeline?.events?.total; - const eventsEmittedFirst = firstStats.metrics?.libbeat?.pipeline?.events?.published; - const eventsDroppedFirst = firstStats.metrics?.libbeat?.pipeline?.events?.dropped; - const bytesWrittenFirst = firstStats.metrics?.libbeat?.output?.write?.bytes; + const eventsTotalFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.total; + const eventsEmittedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.published; + const eventsDroppedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.dropped; + const bytesWrittenFirst = firstStats?.metrics?.libbeat?.output?.write?.bytes; const eventsTotalLast = stats.metrics?.libbeat?.pipeline?.events?.total; const eventsEmittedLast = stats.metrics?.libbeat?.pipeline?.events?.published; diff --git a/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts b/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts index f6df94f8de138..7677677ea5e75 100644 --- a/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts +++ b/x-pack/plugins/monitoring/server/lib/apm/get_apms.ts @@ -24,9 +24,13 @@ export function handleResponse(response: ElasticsearchResponse, start: number, e return accum; } - const earliestStats = hit.inner_hits.earliest.hits.hits[0]._source.beats_stats; - if (!earliestStats) { - return accum; + let earliestStats = null; + if ( + hit.inner_hits?.earliest?.hits?.hits && + hit.inner_hits?.earliest?.hits?.hits.length > 0 && + hit.inner_hits.earliest.hits.hits[0]._source.beats_stats + ) { + earliestStats = hit.inner_hits.earliest.hits.hits[0]._source.beats_stats; } const uuid = stats?.beat?.uuid; @@ -41,7 +45,7 @@ export function handleResponse(response: ElasticsearchResponse, start: number, e // add the beat const rateOptions = { hitTimestamp: stats.timestamp, - earliestHitTimestamp: earliestStats.timestamp, + earliestHitTimestamp: earliestStats?.timestamp, timeWindowMin: start, timeWindowMax: end, }; @@ -54,14 +58,14 @@ export function handleResponse(response: ElasticsearchResponse, start: number, e const { rate: totalEventsRate } = calculateRate({ latestTotal: stats.metrics?.libbeat?.pipeline?.events?.total, - earliestTotal: earliestStats.metrics?.libbeat?.pipeline?.events?.total, + earliestTotal: earliestStats?.metrics?.libbeat?.pipeline?.events?.total, ...rateOptions, }); const errorsWrittenLatest = stats.metrics?.libbeat?.output?.write?.errors ?? 0; - const errorsWrittenEarliest = earliestStats.metrics?.libbeat?.output?.write?.errors ?? 0; + const errorsWrittenEarliest = earliestStats?.metrics?.libbeat?.output?.write?.errors ?? 0; const errorsReadLatest = stats.metrics?.libbeat?.output?.read?.errors ?? 0; - const errorsReadEarliest = earliestStats.metrics?.libbeat?.output?.read?.errors ?? 0; + const errorsReadEarliest = earliestStats?.metrics?.libbeat?.output?.read?.errors ?? 0; const errors = getDiffCalculation( errorsWrittenLatest + errorsReadLatest, errorsWrittenEarliest + errorsReadEarliest diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts index 57325673a131a..80b5efda4047a 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beat_summary.ts @@ -18,8 +18,18 @@ export function handleResponse(response: ElasticsearchResponse, beatUuid: string return {}; } - const firstStats = response.hits.hits[0].inner_hits.first_hit.hits.hits[0]._source.beats_stats; - const stats = response.hits.hits[0]._source.beats_stats; + const firstHit = response.hits.hits[0]; + + let firstStats = null; + if ( + firstHit.inner_hits?.first_hit?.hits?.hits && + firstHit.inner_hits?.first_hit?.hits?.hits.length > 0 && + firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats + ) { + firstStats = firstHit.inner_hits.first_hit.hits.hits[0]._source.beats_stats; + } + + const stats = firstHit._source.beats_stats ?? {}; const eventsTotalFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.total ?? null; const eventsEmittedFirst = firstStats?.metrics?.libbeat?.pipeline?.events?.published ?? null; diff --git a/x-pack/plugins/monitoring/server/lib/beats/get_beats.js b/x-pack/plugins/monitoring/server/lib/beats/get_beats.ts similarity index 64% rename from x-pack/plugins/monitoring/server/lib/beats/get_beats.js rename to x-pack/plugins/monitoring/server/lib/beats/get_beats.ts index af4b6c31a3e5e..beda4334b4937 100644 --- a/x-pack/plugins/monitoring/server/lib/beats/get_beats.js +++ b/x-pack/plugins/monitoring/server/lib/beats/get_beats.ts @@ -5,18 +5,39 @@ */ import moment from 'moment'; -import { upperFirst, get } from 'lodash'; +import { upperFirst } from 'lodash'; +// @ts-ignore import { checkParam } from '../error_missing_required'; +// @ts-ignore import { createBeatsQuery } from './create_beats_query'; +// @ts-ignore import { calculateRate } from '../calculate_rate'; +// @ts-ignore import { getDiffCalculation } from './_beats_stats'; +import { ElasticsearchResponse, LegacyRequest } from '../../types'; + +interface Beat { + uuid: string | undefined; + name: string | undefined; + type: string | undefined; + output: string | undefined; + total_events_rate: number; + bytes_sent_rate: number; + memory: number | undefined; + version: string | undefined; + errors: any; +} -export function handleResponse(response, start, end) { - const hits = get(response, 'hits.hits', []); - const initial = { ids: new Set(), beats: [] }; +export function handleResponse(response: ElasticsearchResponse, start: number, end: number) { + const hits = response.hits?.hits ?? []; + const initial: { ids: Set; beats: Beat[] } = { ids: new Set(), beats: [] }; const { beats } = hits.reduce((accum, hit) => { - const stats = get(hit, '_source.beats_stats'); - const uuid = get(stats, 'beat.uuid'); + const stats = hit._source.beats_stats; + const uuid = stats?.beat?.uuid; + + if (!uuid) { + return accum; + } // skip this duplicated beat, newer one was already added if (accum.ids.has(uuid)) { @@ -25,47 +46,55 @@ export function handleResponse(response, start, end) { // add another beat summary accum.ids.add(uuid); - const earliestStats = get(hit, 'inner_hits.earliest.hits.hits[0]._source.beats_stats'); + + let earliestStats = null; + if ( + hit.inner_hits?.earliest?.hits?.hits && + hit.inner_hits?.earliest?.hits?.hits.length > 0 && + hit.inner_hits.earliest.hits.hits[0]._source.beats_stats + ) { + earliestStats = hit.inner_hits.earliest.hits.hits[0]._source.beats_stats; + } // add the beat const rateOptions = { - hitTimestamp: get(stats, 'timestamp'), - earliestHitTimestamp: get(earliestStats, 'timestamp'), + hitTimestamp: stats?.timestamp, + earliestHitTimestamp: earliestStats?.timestamp, timeWindowMin: start, timeWindowMax: end, }; const { rate: bytesSentRate } = calculateRate({ - latestTotal: get(stats, 'metrics.libbeat.output.write.bytes'), - earliestTotal: get(earliestStats, 'metrics.libbeat.output.write.bytes'), + latestTotal: stats?.metrics?.libbeat?.output?.write?.bytes, + earliestTotal: earliestStats?.metrics?.libbeat?.output?.write?.bytes, ...rateOptions, }); const { rate: totalEventsRate } = calculateRate({ - latestTotal: get(stats, 'metrics.libbeat.pipeline.events.total'), - earliestTotal: get(earliestStats, 'metrics.libbeat.pipeline.events.total'), + latestTotal: stats?.metrics?.libbeat?.pipeline?.events?.total, + earliestTotal: earliestStats?.metrics?.libbeat?.pipeline?.events?.total, ...rateOptions, }); - const errorsWrittenLatest = get(stats, 'metrics.libbeat.output.write.errors'); - const errorsWrittenEarliest = get(earliestStats, 'metrics.libbeat.output.write.errors'); - const errorsReadLatest = get(stats, 'metrics.libbeat.output.read.errors'); - const errorsReadEarliest = get(earliestStats, 'metrics.libbeat.output.read.errors'); + const errorsWrittenLatest = stats?.metrics?.libbeat?.output?.write?.errors ?? 0; + const errorsWrittenEarliest = earliestStats?.metrics?.libbeat?.output?.write?.errors ?? 0; + const errorsReadLatest = stats?.metrics?.libbeat?.output?.read?.errors ?? 0; + const errorsReadEarliest = earliestStats?.metrics?.libbeat?.output?.read?.errors ?? 0; const errors = getDiffCalculation( errorsWrittenLatest + errorsReadLatest, errorsWrittenEarliest + errorsReadEarliest ); accum.beats.push({ - uuid: get(stats, 'beat.uuid'), - name: get(stats, 'beat.name'), - type: upperFirst(get(stats, 'beat.type')), - output: upperFirst(get(stats, 'metrics.libbeat.output.type')), + uuid: stats?.beat?.uuid, + name: stats?.beat?.name, + type: upperFirst(stats?.beat?.type), + output: upperFirst(stats?.metrics?.libbeat?.output?.type), total_events_rate: totalEventsRate, bytes_sent_rate: bytesSentRate, errors, - memory: get(stats, 'metrics.beat.memstats.memory_alloc'), - version: get(stats, 'beat.version'), + memory: stats?.metrics?.beat?.memstats?.memory_alloc, + version: stats?.beat?.version, }); return accum; @@ -74,7 +103,7 @@ export function handleResponse(response, start, end) { return beats; } -export async function getBeats(req, beatsIndexPattern, clusterUuid) { +export async function getBeats(req: LegacyRequest, beatsIndexPattern: string, clusterUuid: string) { checkParam(beatsIndexPattern, 'beatsIndexPattern in getBeats'); const config = req.server.config(); diff --git a/x-pack/plugins/monitoring/server/types.ts b/x-pack/plugins/monitoring/server/types.ts index 73eea99467c59..84b331df8ba42 100644 --- a/x-pack/plugins/monitoring/server/types.ts +++ b/x-pack/plugins/monitoring/server/types.ts @@ -121,9 +121,9 @@ export interface ElasticsearchResponse { export interface ElasticsearchResponseHit { _source: ElasticsearchSource; - inner_hits: { + inner_hits?: { [field: string]: { - hits: { + hits?: { hits: ElasticsearchResponseHit[]; total: { value: number; diff --git a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx index 3880dcdcde0be..d672525f1a937 100644 --- a/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx +++ b/x-pack/plugins/observability/public/hooks/use_chart_theme.tsx @@ -14,6 +14,12 @@ export function useChartTheme() { return { ...baseChartTheme, + chartMargins: { + left: 10, + right: 10, + top: 10, + bottom: 10, + }, background: { ...baseChartTheme.background, color: 'transparent', diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 0bb2f8ba1a246..59184562b67ff 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -8,8 +8,11 @@ import { KibanaRequest } from 'src/core/server'; import { AuthenticationResult } from '../authentication/authentication_result'; /** - * Audit event schema using ECS format. - * https://www.elastic.co/guide/en/ecs/1.6/index.html + * Audit event schema using ECS format: https://www.elastic.co/guide/en/ecs/1.6/index.html + * + * If you add additional fields to the schema ensure you update the Kibana Filebeat module: + * https://github.com/elastic/beats/tree/master/filebeat/module/kibana + * * @public */ export interface AuditEvent { @@ -37,20 +40,45 @@ export interface AuditEvent { }; kibana?: { /** - * Current space id of the request. + * The ID of the space associated with this event. */ space_id?: string; /** - * Saved object that was created, changed, deleted or accessed as part of the action. + * The ID of the user session associated with this event. Each login attempt + * results in a unique session id. + */ + session_id?: string; + /** + * Saved object that was created, changed, deleted or accessed as part of this event. */ saved_object?: { type: string; id: string; }; /** - * Any additional event specific fields. + * Name of authentication provider associated with a login event. + */ + authentication_provider?: string; + /** + * Type of authentication provider associated with a login event. + */ + authentication_type?: string; + /** + * Name of Elasticsearch realm that has authenticated the user. + */ + authentication_realm?: string; + /** + * Name of Elasticsearch realm where the user details were retrieved from. + */ + lookup_realm?: string; + /** + * Set of space IDs that a saved object was shared to. + */ + add_to_spaces?: readonly string[]; + /** + * Set of space IDs that a saved object was removed from. */ - [x: string]: any; + delete_from_spaces?: readonly string[]; }; error?: { code?: string; diff --git a/x-pack/plugins/security/server/audit/audit_service.test.ts b/x-pack/plugins/security/server/audit/audit_service.test.ts index a9c7668871248..91c656ad69f18 100644 --- a/x-pack/plugins/security/server/audit/audit_service.test.ts +++ b/x-pack/plugins/security/server/audit/audit_service.test.ts @@ -27,6 +27,7 @@ const { logging } = coreMock.createSetup(); const http = httpServiceMock.createSetupContract(); const getCurrentUser = jest.fn().mockReturnValue({ username: 'jdoe', roles: ['admin'] }); const getSpaceId = jest.fn().mockReturnValue('default'); +const getSID = jest.fn().mockResolvedValue('SESSION_ID'); beforeEach(() => { logger.info.mockClear(); @@ -45,6 +46,7 @@ describe('#setup', () => { http, getCurrentUser, getSpaceId, + getSID, }) ).toMatchInlineSnapshot(` Object { @@ -70,6 +72,7 @@ describe('#setup', () => { http, getCurrentUser, getSpaceId, + getSID, }); expect(logging.configure).toHaveBeenCalledWith(expect.any(Observable)); }); @@ -82,6 +85,7 @@ describe('#setup', () => { http, getCurrentUser, getSpaceId, + getSID, }); expect(http.registerOnPostAuth).toHaveBeenCalledWith(expect.any(Function)); }); @@ -96,16 +100,17 @@ describe('#asScoped', () => { http, getCurrentUser, getSpaceId, + getSID, }); const request = httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' }, }); - audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } }); + await audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } }); expect(logger.info).toHaveBeenCalledWith('MESSAGE', { ecs: { version: '1.6.0' }, event: { action: 'ACTION' }, - kibana: { space_id: 'default' }, + kibana: { space_id: 'default', session_id: 'SESSION_ID' }, message: 'MESSAGE', trace: { id: 'REQUEST_ID' }, user: { name: 'jdoe', roles: ['admin'] }, @@ -123,12 +128,13 @@ describe('#asScoped', () => { http, getCurrentUser, getSpaceId, + getSID, }); const request = httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' }, }); - audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } }); + await audit.asScoped(request).log({ message: 'MESSAGE', event: { action: 'ACTION' } }); expect(logger.info).not.toHaveBeenCalled(); }); @@ -143,12 +149,13 @@ describe('#asScoped', () => { http, getCurrentUser, getSpaceId, + getSID, }); const request = httpServerMock.createKibanaRequest({ kibanaRequestState: { requestId: 'REQUEST_ID', requestUuid: 'REQUEST_UUID' }, }); - audit.asScoped(request).log(undefined); + await audit.asScoped(request).log(undefined); expect(logger.info).not.toHaveBeenCalled(); }); }); @@ -368,6 +375,7 @@ describe('#getLogger', () => { http, getCurrentUser, getSpaceId, + getSID, }); const auditLogger = auditService.getLogger(pluginId); @@ -398,6 +406,7 @@ describe('#getLogger', () => { http, getCurrentUser, getSpaceId, + getSID, }); const auditLogger = auditService.getLogger(pluginId); @@ -436,6 +445,7 @@ describe('#getLogger', () => { http, getCurrentUser, getSpaceId, + getSID, }); const auditLogger = auditService.getLogger(pluginId); @@ -464,6 +474,7 @@ describe('#getLogger', () => { http, getCurrentUser, getSpaceId, + getSID, }); const auditLogger = auditService.getLogger(pluginId); @@ -493,6 +504,7 @@ describe('#getLogger', () => { http, getCurrentUser, getSpaceId, + getSID, }); const auditLogger = auditService.getLogger(pluginId); diff --git a/x-pack/plugins/security/server/audit/audit_service.ts b/x-pack/plugins/security/server/audit/audit_service.ts index 8dbdc48c7dee9..4ad1f873581c9 100644 --- a/x-pack/plugins/security/server/audit/audit_service.ts +++ b/x-pack/plugins/security/server/audit/audit_service.ts @@ -36,9 +36,6 @@ interface AuditLogMeta extends AuditEvent { ecs: { version: string; }; - session?: { - id: string; - }; trace: { id: string; }; @@ -57,6 +54,7 @@ interface AuditServiceSetupParams { getCurrentUser( request: KibanaRequest ): ReturnType | undefined; + getSID(request: KibanaRequest): Promise; getSpaceId( request: KibanaRequest ): ReturnType | undefined; @@ -84,6 +82,7 @@ export class AuditService { logging, http, getCurrentUser, + getSID, getSpaceId, }: AuditServiceSetupParams): AuditServiceSetup { if (config.enabled && !config.appender) { @@ -134,12 +133,13 @@ export class AuditService { * }); * ``` */ - const log: AuditLogger['log'] = (event) => { + const log: AuditLogger['log'] = async (event) => { if (!event) { return; } - const user = getCurrentUser(request); const spaceId = getSpaceId(request); + const user = getCurrentUser(request); + const sessionId = await getSID(request); const meta: AuditLogMeta = { ecs: { version: ECS_VERSION }, ...event, @@ -151,11 +151,10 @@ export class AuditService { event.user, kibana: { space_id: spaceId, + session_id: sessionId, ...event.kibana, }, - trace: { - id: request.id, - }, + trace: { id: request.id }, }; if (filterEvent(meta, config.ignore_filters)) { this.ecsLogger.info(event.message!, meta); diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 4016b78b6d998..070e187e869b1 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -188,24 +188,25 @@ export class Plugin { registerSecurityUsageCollector({ usageCollection, config, license }); + const { session } = this.sessionManagementService.setup({ + config, + clusterClient, + http: core.http, + kibanaIndexName: legacyConfig.kibana.index, + taskManager, + }); + const audit = this.auditService.setup({ license, config: config.audit, logging: core.logging, http: core.http, getSpaceId: (request) => spaces?.spacesService.getSpaceId(request), + getSID: (request) => session.getSID(request), getCurrentUser: (request) => authenticationSetup.getCurrentUser(request), }); const legacyAuditLogger = new SecurityAuditLogger(audit.getLogger()); - const { session } = this.sessionManagementService.setup({ - config, - clusterClient, - http: core.http, - kibanaIndexName: legacyConfig.kibana.index, - taskManager, - }); - const authenticationSetup = this.authenticationService.setup({ legacyAuditLogger, audit, diff --git a/x-pack/plugins/security/server/session_management/session.mock.ts b/x-pack/plugins/security/server/session_management/session.mock.ts index 973341acbfce3..b740249180407 100644 --- a/x-pack/plugins/security/server/session_management/session.mock.ts +++ b/x-pack/plugins/security/server/session_management/session.mock.ts @@ -10,6 +10,7 @@ import { sessionIndexMock } from './session_index.mock'; export const sessionMock = { create: (): jest.Mocked> => ({ + getSID: jest.fn(), get: jest.fn(), create: jest.fn(), update: jest.fn(), diff --git a/x-pack/plugins/security/server/session_management/session.test.ts b/x-pack/plugins/security/server/session_management/session.test.ts index 3010e70c31421..47e391ed57925 100644 --- a/x-pack/plugins/security/server/session_management/session.test.ts +++ b/x-pack/plugins/security/server/session_management/session.test.ts @@ -56,6 +56,20 @@ describe('Session', () => { }); }); + describe('#getSID', () => { + const mockRequest = httpServerMock.createKibanaRequest(); + + it('returns `undefined` if session cookie does not exist', async () => { + mockSessionCookie.get.mockResolvedValue(null); + await expect(session.getSID(mockRequest)).resolves.toBeUndefined(); + }); + + it('returns session id', async () => { + mockSessionCookie.get.mockResolvedValue(sessionCookieMock.createValue()); + await expect(session.getSID(mockRequest)).resolves.toEqual('some-long-sid'); + }); + }); + describe('#get', () => { const mockAAD = Buffer.from([2, ...Array(255).keys()]).toString('base64'); diff --git a/x-pack/plugins/security/server/session_management/session.ts b/x-pack/plugins/security/server/session_management/session.ts index 4dc83a1abe4af..3c97c13c2d41d 100644 --- a/x-pack/plugins/security/server/session_management/session.ts +++ b/x-pack/plugins/security/server/session_management/session.ts @@ -99,6 +99,17 @@ export class Session { this.crypto = nodeCrypto({ encryptionKey: this.options.config.encryptionKey }); } + /** + * Extracts session id for the specified request. + * @param request Request instance to get session value for. + */ + async getSID(request: KibanaRequest) { + const sessionCookieValue = await this.options.sessionCookie.get(request); + if (sessionCookieValue) { + return sessionCookieValue.sid; + } + } + /** * Extracts session value for the specified request. Under the hood it can clear session if it is * invalid or created by the legacy versions of Kibana. diff --git a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts index b3c82a8d9d6f0..3ce507c791f0a 100644 --- a/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts +++ b/x-pack/plugins/security_solution/cypress/integration/alerts_detection_rules_custom.spec.ts @@ -217,7 +217,7 @@ describe('Custom detection rules creation', () => { }); }); -describe('Custom detection rules deletion and edition', () => { +describe.skip('Custom detection rules deletion and edition', () => { beforeEach(() => { esArchiverLoad('custom_rules'); loginAndWaitForPageWithoutDateRange(DETECTIONS_URL); diff --git a/x-pack/plugins/security_solution/cypress/integration/sourcerer.spec.ts b/x-pack/plugins/security_solution/cypress/integration/sourcerer.spec.ts new file mode 100644 index 0000000000000..4126bcfdbf0b4 --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/integration/sourcerer.spec.ts @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { loginAndWaitForPage } from '../tasks/login'; + +import { HOSTS_URL } from '../urls/navigation'; +import { waitForAllHostsToBeLoaded } from '../tasks/hosts/all_hosts'; +import { + clickOutOfSourcererTimeline, + clickTimelineRadio, + deselectSourcererOptions, + isCustomRadio, + isHostsStatValue, + isNotCustomRadio, + isNotSourcererSelection, + isSourcererOptions, + isSourcererSelection, + openSourcerer, + resetSourcerer, + setSourcererOption, + unsetSourcererOption, +} from '../tasks/sourcerer'; +import { openTimelineUsingToggle } from '../tasks/security_main'; +import { populateTimeline } from '../tasks/timeline'; +import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; + +describe('Sourcerer', () => { + beforeEach(() => { + loginAndWaitForPage(HOSTS_URL); + }); + describe('Default scope', () => { + it('has SIEM index patterns selected on initial load', () => { + openSourcerer(); + isSourcererSelection(`auditbeat-*`); + }); + + it('has Kibana index patterns in the options', () => { + openSourcerer(); + isSourcererOptions([`metrics-*`, `logs-*`]); + }); + it('selected KIP gets added to sourcerer', () => { + setSourcererOption(`metrics-*`); + openSourcerer(); + isSourcererSelection(`metrics-*`); + }); + + it('does not return data without correct pattern selected', () => { + waitForAllHostsToBeLoaded(); + isHostsStatValue('4 '); + setSourcererOption(`metrics-*`); + unsetSourcererOption(`auditbeat-*`); + isHostsStatValue('0 '); + }); + + it('reset button restores to original state', () => { + setSourcererOption(`metrics-*`); + openSourcerer(); + isSourcererSelection(`metrics-*`); + resetSourcerer(); + openSourcerer(); + isNotSourcererSelection(`metrics-*`); + }); + }); + describe('Timeline scope', () => { + const alertPatterns = ['.siem-signals-default']; + const rawPatterns = ['auditbeat-*']; + const allPatterns = [...alertPatterns, ...rawPatterns]; + it('Radio buttons select correct sourcerer patterns', () => { + openTimelineUsingToggle(); + openSourcerer('timeline'); + allPatterns.forEach((ss) => isSourcererSelection(ss, 'timeline')); + clickTimelineRadio('raw'); + rawPatterns.forEach((ss) => isSourcererSelection(ss, 'timeline')); + alertPatterns.forEach((ss) => isNotSourcererSelection(ss, 'timeline')); + clickTimelineRadio('alert'); + alertPatterns.forEach((ss) => isSourcererSelection(ss, 'timeline')); + rawPatterns.forEach((ss) => isNotSourcererSelection(ss, 'timeline')); + }); + it('Adding an option results in the custom radio becoming active', () => { + openTimelineUsingToggle(); + openSourcerer('timeline'); + isNotCustomRadio(); + clickOutOfSourcererTimeline(); + const luckyOption = 'logs-*'; + setSourcererOption(luckyOption, 'timeline'); + openSourcerer('timeline'); + isCustomRadio(); + }); + it('Selected index patterns are properly queried', () => { + openTimelineUsingToggle(); + populateTimeline(); + openSourcerer('timeline'); + deselectSourcererOptions(rawPatterns, 'timeline'); + cy.get(SERVER_SIDE_EVENT_COUNT) + .invoke('text') + .then((strCount) => { + const intCount = +strCount; + cy.wrap(intCount).should('eq', 0); + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/cypress/screens/shared.ts b/x-pack/plugins/security_solution/cypress/screens/shared.ts index ccfe0f97c732c..eae8de6d5ee8b 100644 --- a/x-pack/plugins/security_solution/cypress/screens/shared.ts +++ b/x-pack/plugins/security_solution/cypress/screens/shared.ts @@ -6,4 +6,4 @@ export const NOTIFICATION_TOASTS = '[data-test-subj="globalToastList"]'; -export const TOAST_ERROR_CLASS = 'euiToast--danger'; +export const TOAST_ERROR = '.euiToast--danger'; diff --git a/x-pack/plugins/security_solution/cypress/screens/sourcerer.ts b/x-pack/plugins/security_solution/cypress/screens/sourcerer.ts new file mode 100644 index 0000000000000..3f461c425c54d --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/screens/sourcerer.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const SOURCERER_TRIGGER = '[data-test-subj="sourcerer-trigger"]'; +export const SOURCERER_INPUT = + '[data-test-subj="indexPattern-switcher"] [data-test-subj="comboBoxInput"]'; +export const SOURCERER_OPTIONS = + '[data-test-subj="comboBoxOptionsList indexPattern-switcher-optionsList"]'; +export const SOURCERER_SAVE_BUTTON = 'button[data-test-subj="add-index"]'; +export const SOURCERER_RESET_BUTTON = 'button[data-test-subj="sourcerer-reset"]'; +export const SOURCERER_POPOVER_TITLE = '.euiPopoverTitle'; +export const HOSTS_STAT = '[data-test-subj="stat-hosts"] [data-test-subj="stat-title"]'; + +export const SOURCERER_TIMELINE = { + trigger: '[data-test-subj="sourcerer-timeline-trigger"]', + advancedSettings: '[data-test-subj="advanced-settings"]', + sourcerer: '[data-test-subj="timeline-sourcerer"]', + sourcererInput: '[data-test-subj="timeline-sourcerer"] [data-test-subj="comboBoxInput"]', + sourcererOptions: '[data-test-subj="comboBoxOptionsList timeline-sourcerer-optionsList"]', + radioRaw: '[data-test-subj="timeline-sourcerer-radio"] label.euiRadio__label[for="raw"]', + radioAlert: '[data-test-subj="timeline-sourcerer-radio"] label.euiRadio__label[for="alert"]', + radioAll: '[data-test-subj="timeline-sourcerer-radio"] label.euiRadio__label[for="all"]', + radioCustom: '[data-test-subj="timeline-sourcerer-radio"] input.euiRadio__input[id="custom"]', + radioCustomLabel: + '[data-test-subj="timeline-sourcerer-radio"] label.euiRadio__label[for="custom"]', +}; +export const SOURCERER_TIMELINE_ADVANCED = '[data-test-subj="advanced-settings"]'; diff --git a/x-pack/plugins/security_solution/cypress/screens/timeline.ts b/x-pack/plugins/security_solution/cypress/screens/timeline.ts index 9397307684d6a..0f5e8c133f0d0 100644 --- a/x-pack/plugins/security_solution/cypress/screens/timeline.ts +++ b/x-pack/plugins/security_solution/cypress/screens/timeline.ts @@ -40,7 +40,8 @@ export const GRAPH_TAB_BUTTON = '[data-test-subj="timelineTabs-graph"]'; export const HEADER = '[data-test-subj="header"]'; -export const HEADERS_GROUP = '[data-test-subj="headers-group"]'; +export const HEADERS_GROUP = + '[data-test-subj="events-viewer-panel"] [data-test-subj="headers-group"]'; export const ID_HEADER_FIELD = '[data-test-subj="timeline"] [data-test-subj="header-text-_id"]'; diff --git a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts index cb099ea26d37b..026813e4a11cf 100644 --- a/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts +++ b/x-pack/plugins/security_solution/cypress/tasks/create_new_rule.ts @@ -71,7 +71,7 @@ import { MITRE_ATTACK_ADD_SUBTECHNIQUE_BUTTON, MITRE_ATTACK_ADD_TECHNIQUE_BUTTON, } from '../screens/create_new_rule'; -import { NOTIFICATION_TOASTS, TOAST_ERROR_CLASS } from '../screens/shared'; +import { TOAST_ERROR } from '../screens/shared'; import { SERVER_SIDE_EVENT_COUNT } from '../screens/timeline'; import { TIMELINE } from '../screens/timelines'; import { refreshPage } from './security_header'; @@ -262,11 +262,20 @@ export const fillDefineThresholdRuleAndContinue = (rule: ThresholdRule) => { }; export const fillDefineEqlRuleAndContinue = (rule: CustomRule) => { + cy.get(EQL_QUERY_INPUT).should('exist'); + cy.get(EQL_QUERY_INPUT).should('be.visible'); cy.get(EQL_QUERY_INPUT).type(rule.customQuery!); cy.get(EQL_QUERY_VALIDATION_SPINNER).should('not.exist'); cy.get(QUERY_PREVIEW_BUTTON).should('not.be.disabled').click({ force: true }); - cy.get(EQL_QUERY_PREVIEW_HISTOGRAM).should('contain.text', 'Hits'); - cy.get(NOTIFICATION_TOASTS).children().should('not.have.class', TOAST_ERROR_CLASS); // asserts no error toast on page + cy.get(EQL_QUERY_PREVIEW_HISTOGRAM) + .invoke('text') + .then((text) => { + if (text !== 'Hits') { + cy.get(QUERY_PREVIEW_BUTTON).click({ force: true }); + cy.get(EQL_QUERY_PREVIEW_HISTOGRAM).should('contain.text', 'Hits'); + } + }); + cy.get(TOAST_ERROR).should('not.exist'); cy.get(DEFINE_CONTINUE_BUTTON).should('exist').click({ force: true }); cy.get(EQL_QUERY_INPUT).should('not.exist'); diff --git a/x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts b/x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts new file mode 100644 index 0000000000000..b224f81ab8f2f --- /dev/null +++ b/x-pack/plugins/security_solution/cypress/tasks/sourcerer.ts @@ -0,0 +1,136 @@ +/* + * 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 { + HOSTS_STAT, + SOURCERER_INPUT, + SOURCERER_OPTIONS, + SOURCERER_POPOVER_TITLE, + SOURCERER_RESET_BUTTON, + SOURCERER_SAVE_BUTTON, + SOURCERER_TIMELINE, + SOURCERER_TRIGGER, +} from '../screens/sourcerer'; +import { TIMELINE_TITLE } from '../screens/timeline'; + +export const openSourcerer = (sourcererScope?: string) => { + if (sourcererScope != null && sourcererScope === 'timeline') { + return openTimelineSourcerer(); + } + cy.get(SOURCERER_TRIGGER).should('be.enabled'); + cy.get(SOURCERER_TRIGGER).should('be.visible'); + cy.get(SOURCERER_TRIGGER).click(); +}; +export const openTimelineSourcerer = () => { + cy.get(SOURCERER_TIMELINE.trigger).should('be.enabled'); + cy.get(SOURCERER_TIMELINE.trigger).should('be.visible'); + cy.get(SOURCERER_TIMELINE.trigger).click(); + cy.get(SOURCERER_TIMELINE.advancedSettings).should(($div) => { + if ($div.text() === 'Show Advanced') { + $div.click(); + } + expect(true).to.eq(true); + }); +}; +export const openAdvancedSettings = () => {}; + +export const clickOutOfSelector = () => { + return cy.get(SOURCERER_POPOVER_TITLE).first().click(); +}; + +const getScopedSelectors = (sourcererScope?: string): { input: string; options: string } => + sourcererScope != null && sourcererScope === 'timeline' + ? { input: SOURCERER_TIMELINE.sourcererInput, options: SOURCERER_TIMELINE.sourcererOptions } + : { input: SOURCERER_INPUT, options: SOURCERER_OPTIONS }; + +export const isSourcererSelection = (patternName: string, sourcererScope?: string) => { + const { input } = getScopedSelectors(sourcererScope); + return cy.get(input).find(`span[title="${patternName}"]`).should('exist'); +}; + +export const isHostsStatValue = (value: string) => { + return cy.get(HOSTS_STAT).first().should('have.text', value); +}; + +export const isNotSourcererSelection = (patternName: string, sourcererScope?: string) => { + const { input } = getScopedSelectors(sourcererScope); + return cy.get(input).find(`span[title="${patternName}"]`).should('not.exist'); +}; + +export const isSourcererOptions = (patternNames: string[], sourcererScope?: string) => { + const { input, options } = getScopedSelectors(sourcererScope); + cy.get(input).click(); + return patternNames.every((patternName) => { + return cy + .get(options) + .find(`button.euiFilterSelectItem[title="${patternName}"]`) + .its('length') + .should('eq', 1); + }); +}; + +export const selectSourcererOption = (patternName: string, sourcererScope?: string) => { + const { input, options } = getScopedSelectors(sourcererScope); + cy.get(input).click(); + cy.get(options).find(`button.euiFilterSelectItem[title="${patternName}"]`).click(); + clickOutOfSelector(); + return cy.get(SOURCERER_SAVE_BUTTON).click({ force: true }); +}; + +export const deselectSourcererOption = (patternName: string, sourcererScope?: string) => { + const { input } = getScopedSelectors(sourcererScope); + cy.get(input).find(`span[title="${patternName}"] button`).click(); + clickOutOfSelector(); + return cy.get(SOURCERER_SAVE_BUTTON).click({ force: true }); +}; + +export const deselectSourcererOptions = (patternNames: string[], sourcererScope?: string) => { + const { input } = getScopedSelectors(sourcererScope); + patternNames.forEach((patternName) => + cy.get(input).find(`span[title="${patternName}"] button`).click() + ); + clickOutOfSelector(); + return cy.get(SOURCERER_SAVE_BUTTON).click({ force: true }); +}; + +export const resetSourcerer = () => { + cy.get(SOURCERER_RESET_BUTTON).click(); + clickOutOfSelector(); + return cy.get(SOURCERER_SAVE_BUTTON).click({ force: true }); +}; + +export const setSourcererOption = (patternName: string, sourcererScope?: string) => { + openSourcerer(sourcererScope); + isNotSourcererSelection(patternName, sourcererScope); + selectSourcererOption(patternName, sourcererScope); +}; + +export const unsetSourcererOption = (patternName: string, sourcererScope?: string) => { + openSourcerer(sourcererScope); + isSourcererSelection(patternName, sourcererScope); + deselectSourcererOption(patternName, sourcererScope); +}; + +export const clickTimelineRadio = (radioName: string) => { + let theRadio = SOURCERER_TIMELINE.radioAll; + if (radioName === 'alert') { + theRadio = SOURCERER_TIMELINE.radioAlert; + } + if (radioName === 'raw') { + theRadio = SOURCERER_TIMELINE.radioRaw; + } + return cy.get(theRadio).first().click(); +}; + +export const isCustomRadio = () => { + return cy.get(SOURCERER_TIMELINE.radioCustom).should('be.enabled'); +}; + +export const isNotCustomRadio = () => { + return cy.get(SOURCERER_TIMELINE.radioCustom).should('be.disabled'); +}; + +export const clickOutOfSourcererTimeline = () => cy.get(TIMELINE_TITLE).first().click(); diff --git a/x-pack/plugins/security_solution/public/app/app.tsx b/x-pack/plugins/security_solution/public/app/app.tsx index 54b02c374e43f..ffed557f28511 100644 --- a/x-pack/plugins/security_solution/public/app/app.tsx +++ b/x-pack/plugins/security_solution/public/app/app.tsx @@ -14,6 +14,7 @@ import { ThemeProvider } from 'styled-components'; import { EuiErrorBoundary } from '@elastic/eui'; import euiDarkVars from '@elastic/eui/dist/eui_theme_dark.json'; import euiLightVars from '@elastic/eui/dist/eui_theme_light.json'; +import { AppLeaveHandler } from '../../../../../src/core/public'; import { ManageUserInfo } from '../detections/components/user_info'; import { DEFAULT_DARK_MODE, APP_NAME } from '../../common/constants'; @@ -28,13 +29,21 @@ import { ApolloClientContext } from '../common/utils/apollo_context'; import { ManageGlobalTimeline } from '../timelines/components/manage_timeline'; import { StartServices } from '../types'; import { PageRouter } from './routes'; + interface StartAppComponent extends AppFrontendLibs { children: React.ReactNode; history: History; + onAppLeave: (handler: AppLeaveHandler) => void; store: Store; } -const StartAppComponent: FC = ({ children, apolloClient, history, store }) => { +const StartAppComponent: FC = ({ + children, + apolloClient, + history, + onAppLeave, + store, +}) => { const { i18n } = useKibana().services; const [darkMode] = useUiSetting$(DEFAULT_DARK_MODE); @@ -57,7 +66,9 @@ const StartAppComponent: FC = ({ children, apolloClient, hist - {children} + + {children} + @@ -78,6 +89,7 @@ const StartApp = memo(StartAppComponent); interface SecurityAppComponentProps extends AppFrontendLibs { children: React.ReactNode; history: History; + onAppLeave: (handler: AppLeaveHandler) => void; services: StartServices; store: Store; } @@ -86,6 +98,7 @@ const SecurityAppComponent: React.FC = ({ children, apolloClient, history, + onAppLeave, services, store, }) => ( @@ -95,7 +108,7 @@ const SecurityAppComponent: React.FC = ({ ...services, }} > - + {children} diff --git a/x-pack/plugins/security_solution/public/app/home/index.tsx b/x-pack/plugins/security_solution/public/app/home/index.tsx index 3b64c1f7f1f65..30c4e87f695b2 100644 --- a/x-pack/plugins/security_solution/public/app/home/index.tsx +++ b/x-pack/plugins/security_solution/public/app/home/index.tsx @@ -23,6 +23,7 @@ import { DETECTIONS_SUB_PLUGIN_ID } from '../../../common/constants'; import { SourcererScopeName } from '../../common/store/sourcerer/model'; import { useUpgradeEndpointPackage } from '../../common/hooks/endpoint/upgrade'; import { useThrottledResizeObserver } from '../../common/components/utils'; +import { AppLeaveHandler } from '../../../../../../src/core/public'; const Main = styled.main.attrs<{ paddingTop: number }>(({ paddingTop }) => ({ style: { @@ -39,9 +40,10 @@ Main.displayName = 'Main'; interface HomePageProps { children: React.ReactNode; + onAppLeave: (handler: AppLeaveHandler) => void; } -const HomePageComponent: React.FC = ({ children }) => { +const HomePageComponent: React.FC = ({ children, onAppLeave }) => { const { application, overlays } = useKibana().services; const subPluginId = useRef(''); const { ref, height = 0 } = useThrottledResizeObserver(300); @@ -87,7 +89,7 @@ const HomePageComponent: React.FC = ({ children }) => { {indicesExist && showTimeline && ( <> - + )} diff --git a/x-pack/plugins/security_solution/public/app/index.tsx b/x-pack/plugins/security_solution/public/app/index.tsx index 4c8e87c4abfba..d45c5393c01d6 100644 --- a/x-pack/plugins/security_solution/public/app/index.tsx +++ b/x-pack/plugins/security_solution/public/app/index.tsx @@ -14,12 +14,19 @@ export const renderApp = ({ apolloClient, element, history, + onAppLeave, services, store, SubPluginRoutes, }: RenderAppProps): (() => void) => { render( - + , element diff --git a/x-pack/plugins/security_solution/public/app/routes.tsx b/x-pack/plugins/security_solution/public/app/routes.tsx index 1d3a59856caa9..ed6d1f319b7e6 100644 --- a/x-pack/plugins/security_solution/public/app/routes.tsx +++ b/x-pack/plugins/security_solution/public/app/routes.tsx @@ -7,20 +7,22 @@ import { History } from 'history'; import React, { FC, memo, useEffect } from 'react'; import { Route, Router, Switch } from 'react-router-dom'; - import { useDispatch } from 'react-redux'; -import { NotFoundPage } from './404'; -import { HomePage } from './home'; + +import { AppLeaveHandler } from '../../../../../src/core/public'; import { ManageRoutesSpy } from '../common/utils/route/manage_spy_routes'; import { RouteCapture } from '../common/components/endpoint/route_capture'; import { AppAction } from '../common/store/actions'; +import { NotFoundPage } from './404'; +import { HomePage } from './home'; interface RouterProps { children: React.ReactNode; history: History; + onAppLeave: (handler: AppLeaveHandler) => void; } -const PageRouterComponent: FC = ({ history, children }) => { +const PageRouterComponent: FC = ({ children, history, onAppLeave }) => { const dispatch = useDispatch<(action: AppAction) => void>(); useEffect(() => { return () => { @@ -39,7 +41,7 @@ const PageRouterComponent: FC = ({ history, children }) => { - {children} + {children} diff --git a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx index 755dde9341dca..78bb3a8d2f2f3 100644 --- a/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/all_cases/index.test.tsx @@ -474,6 +474,9 @@ describe('AllCases', () => { username: 'lknope', }, version: 'WzQ3LDFd', + settings: { + syncAlerts: true, + }, }); }); }); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx index 945458e92bc8a..62ce0cc2cc2f5 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_action_bar/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; import styled, { css } from 'styled-components'; import { EuiButtonEmpty, @@ -13,6 +13,7 @@ import { EuiDescriptionListTitle, EuiFlexGroup, EuiFlexItem, + EuiIconTip, } from '@elastic/eui'; import { CaseStatuses } from '../../../../../case/common/api'; import * as i18n from '../case_view/translations'; @@ -22,6 +23,8 @@ import { Case } from '../../containers/types'; import { CaseService } from '../../containers/use_get_case_user_actions'; import { StatusContextMenu } from './status_context_menu'; import { getStatusDate, getStatusTitle } from './helpers'; +import { SyncAlertsSwitch } from '../case_settings/sync_alerts_switch'; +import { OnUpdateFields } from '../case_view'; const MyDescriptionList = styled(EuiDescriptionList)` ${({ theme }) => css` @@ -38,7 +41,7 @@ interface CaseActionBarProps { disabled?: boolean; isLoading: boolean; onRefresh: () => void; - onStatusChanged: (status: CaseStatuses) => void; + onUpdateField: (args: OnUpdateFields) => void; } const CaseActionBarComponent: React.FC = ({ caseData, @@ -46,10 +49,27 @@ const CaseActionBarComponent: React.FC = ({ disabled = false, isLoading, onRefresh, - onStatusChanged, + onUpdateField, }) => { const date = useMemo(() => getStatusDate(caseData), [caseData]); const title = useMemo(() => getStatusTitle(caseData.status), [caseData.status]); + const onStatusChanged = useCallback( + (status: CaseStatuses) => + onUpdateField({ + key: 'status', + value: status, + }), + [onUpdateField] + ); + + const onSyncAlertsChanged = useCallback( + (syncAlerts: boolean) => + onUpdateField({ + key: 'settings', + value: { ...caseData.settings, syncAlerts }, + }), + [caseData.settings, onUpdateField] + ); return ( @@ -78,20 +98,41 @@ const CaseActionBarComponent: React.FC = ({ - - - - {i18n.CASE_REFRESH} - - - - - - + + + + + + + + {i18n.STATUS} + + + + + + + + + + + + {i18n.CASE_REFRESH} + + + + + + + ); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx b/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx new file mode 100644 index 0000000000000..ab91f2ae8cdf3 --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/case_settings/sync_alerts_switch.tsx @@ -0,0 +1,48 @@ +/* + * 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, { memo, useCallback, useState } from 'react'; +import { EuiSwitch } from '@elastic/eui'; + +import * as i18n from '../../translations'; + +interface Props { + disabled: boolean; + isSynced?: boolean; + showLabel?: boolean; + onSwitchChange?: (isSynced: boolean) => void; +} + +const SyncAlertsSwitchComponent: React.FC = ({ + disabled, + isSynced = true, + showLabel = false, + onSwitchChange, +}) => { + const [isOn, setIsOn] = useState(isSynced); + + const onChange = useCallback(() => { + if (onSwitchChange) { + onSwitchChange(!isOn); + } + + setIsOn(!isOn); + }, [isOn, onSwitchChange]); + + return ( + + ); +}; + +SyncAlertsSwitchComponent.displayName = 'SyncAlertsSwitchComponent'; + +export const SyncAlertsSwitch = memo(SyncAlertsSwitchComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx index 0e6226f69fce7..6007038b33ab7 100644 --- a/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/case_view/index.tsx @@ -16,7 +16,7 @@ import { EuiHorizontalRule, } from '@elastic/eui'; -import { CaseStatuses } from '../../../../../case/common/api'; +import { CaseStatuses, CaseAttributes } from '../../../../../case/common/api'; import { Case, CaseConnector } from '../../containers/types'; import { getCaseDetailsUrl, getCaseUrl, useFormatUrl } from '../../../common/components/link_to'; import { gutterTimeline } from '../../../common/lib/helpers'; @@ -234,6 +234,21 @@ export const CaseComponent = React.memo( onError, }); } + break; + case 'settings': + const settingsUpdate = getTypedPayload(value); + if (caseData.settings !== value) { + updateCaseProperty({ + fetchCaseUserActions, + updateKey: 'settings', + updateValue: settingsUpdate, + updateCase: handleUpdateNewCase, + version: caseData.version, + onSuccess, + onError, + }); + } + break; default: return null; } @@ -397,9 +412,9 @@ export const CaseComponent = React.memo( currentExternalIncident={currentExternalIncident} caseData={caseData} disabled={!userCanCrud} - isLoading={isLoading && updateKey === 'status'} + isLoading={isLoading && (updateKey === 'status' || updateKey === 'settings')} onRefresh={handleRefresh} - onStatusChanged={changeStatus} + onUpdateField={onUpdateField} /> diff --git a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx index b2a0f3c351552..67c536f652ec1 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/connector.tsx @@ -7,13 +7,13 @@ import React, { memo, useEffect } from 'react'; import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { ConnectorTypeFields } from '../../../../../case/common/api/connectors'; import { UseField, useFormData, FieldHook } from '../../../shared_imports'; import { useConnectors } from '../../containers/configure/use_connectors'; import { ConnectorSelector } from '../connector_selector/form'; import { SettingFieldsForm } from '../settings/fields_form'; import { ActionConnector } from '../../containers/types'; import { getConnectorById } from '../configure_cases/utils'; +import { FormProps } from './schema'; interface Props { isLoading: boolean; @@ -21,7 +21,7 @@ interface Props { interface SettingsFieldProps { connectors: ActionConnector[]; - field: FieldHook; + field: FieldHook; isEdit: boolean; } diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx index e64b2b3a05080..3091e6b33d333 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form.test.tsx @@ -25,6 +25,7 @@ const initialCaseValue: FormProps = { title: '', connectorId: 'none', fields: null, + syncAlerts: true, }; describe('CreateCaseForm', () => { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form.tsx index 40db4d792c1c8..308dc63916934 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form.tsx @@ -15,6 +15,7 @@ import { Description } from './description'; import { Tags } from './tags'; import { Connector } from './connector'; import * as i18n from './translations'; +import { SyncAlertsToggle } from './sync_alerts_toggle'; interface ContainerProps { big?: boolean; @@ -61,6 +62,18 @@ export const CreateCaseForm: React.FC = React.memo(({ withSteps = true }) const secondStep = useMemo( () => ({ title: i18n.STEP_TWO_TITLE, + children: ( + + + + ), + }), + [isSubmitting] + ); + + const thirdStep = useMemo( + () => ({ + title: i18n.STEP_THREE_TITLE, children: ( @@ -70,7 +83,11 @@ export const CreateCaseForm: React.FC = React.memo(({ withSteps = true }) [isSubmitting] ); - const allSteps = useMemo(() => [firstStep, secondStep], [firstStep, secondStep]); + const allSteps = useMemo(() => [firstStep, secondStep, thirdStep], [ + firstStep, + secondStep, + thirdStep, + ]); return ( <> @@ -85,6 +102,7 @@ export const CreateCaseForm: React.FC = React.memo(({ withSteps = true }) <> {firstStep.children} {secondStep.children} + {thirdStep.children} )} diff --git a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx index e11e508b60ebf..4575059a5a6c0 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/form_context.tsx @@ -23,6 +23,7 @@ const initialCaseValue: FormProps = { title: '', connectorId: 'none', fields: null, + syncAlerts: true, }; interface Props { @@ -34,14 +35,21 @@ export const FormContext: React.FC = ({ children, onSuccess }) => { const { caseData, postCase } = usePostCase(); const submitCase = useCallback( - async ({ connectorId: dataConnectorId, fields, ...dataWithoutConnectorId }, isValid) => { + async ( + { connectorId: dataConnectorId, fields, syncAlerts, ...dataWithoutConnectorId }, + isValid + ) => { if (isValid) { const caseConnector = getConnectorById(dataConnectorId, connectors); const connectorToUpdate = caseConnector ? normalizeActionConnector(caseConnector, fields) : getNoneConnector(); - await postCase({ ...dataWithoutConnectorId, connector: connectorToUpdate }); + await postCase({ + ...dataWithoutConnectorId, + connector: connectorToUpdate, + settings: { syncAlerts }, + }); } }, [postCase, connectors] diff --git a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx index 29073e7774158..fe5b3bea6445c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/index.test.tsx @@ -8,8 +8,9 @@ import React from 'react'; import { mount, ReactWrapper } from 'enzyme'; import { act, waitFor } from '@testing-library/react'; import { EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; -import { TestProviders } from '../../../common/mock'; +import { CasePostRequest } from '../../../../../case/common/api'; +import { TestProviders } from '../../../common/mock'; import { usePostCase } from '../../containers/use_post_case'; import { useGetTags } from '../../containers/use_get_tags'; import { useConnectors } from '../../containers/configure/use_connectors'; @@ -41,7 +42,7 @@ const useGetFieldsByIssueTypeMock = useGetFieldsByIssueType as jest.Mock; const postCase = jest.fn(); const sampleTags = ['coke', 'pepsi']; -const sampleData = { +const sampleData: CasePostRequest = { description: 'what a great description', tags: sampleTags, title: 'what a cool title', @@ -51,6 +52,9 @@ const sampleData = { name: 'none', type: ConnectorTypes.none, }, + settings: { + syncAlerts: true, + }, }; const defaultPostCase = { diff --git a/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx b/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx index a336860121c94..34f0bdd051483 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/create/schema.tsx @@ -6,7 +6,7 @@ import { CasePostRequest, ConnectorTypeFields } from '../../../../../case/common/api'; import { FIELD_TYPES, fieldValidators, FormSchema } from '../../../shared_imports'; -import * as i18n from '../../translations'; +import * as i18n from './translations'; import { OptionalFieldLabel } from './optional_field_label'; const { emptyField } = fieldValidators; @@ -18,9 +18,10 @@ export const schemaTags = { labelAppend: OptionalFieldLabel, }; -export type FormProps = Omit & { +export type FormProps = Omit & { connectorId: string; fields: ConnectorTypeFields['fields']; + syncAlerts: boolean; }; export const schema: FormSchema = { @@ -47,4 +48,10 @@ export const schema: FormSchema = { label: i18n.CONNECTORS, defaultValue: 'none', }, + fields: {}, + syncAlerts: { + helpText: i18n.SYNC_ALERTS_HELP, + type: FIELD_TYPES.TOGGLE, + defaultValue: true, + }, }; diff --git a/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx b/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx new file mode 100644 index 0000000000000..0abb2974dd2cb --- /dev/null +++ b/x-pack/plugins/security_solution/public/cases/components/create/sync_alerts_toggle.tsx @@ -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 React, { memo } from 'react'; +import { Field, getUseField, useFormData } from '../../../shared_imports'; +import * as i18n from './translations'; + +const CommonUseField = getUseField({ component: Field }); + +interface Props { + isLoading: boolean; +} + +const SyncAlertsToggleComponent: React.FC = ({ isLoading }) => { + const [{ syncAlerts }] = useFormData({ watch: ['syncAlerts'] }); + return ( + + ); +}; + +SyncAlertsToggleComponent.displayName = 'SyncAlertsToggleComponent'; + +export const SyncAlertsToggle = memo(SyncAlertsToggleComponent); diff --git a/x-pack/plugins/security_solution/public/cases/components/create/translations.ts b/x-pack/plugins/security_solution/public/cases/components/create/translations.ts index 38916dbddc7d7..f892e080af782 100644 --- a/x-pack/plugins/security_solution/public/cases/components/create/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/components/create/translations.ts @@ -17,7 +17,21 @@ export const STEP_ONE_TITLE = i18n.translate( export const STEP_TWO_TITLE = i18n.translate( 'xpack.securitySolution.components.create.stepTwoTitle', + { + defaultMessage: 'Case settings', + } +); + +export const STEP_THREE_TITLE = i18n.translate( + 'xpack.securitySolution.components.create.stepThreeTitle', { defaultMessage: 'External Connector Fields', } ); + +export const SYNC_ALERTS_LABEL = i18n.translate( + 'xpack.securitySolution.components.create.syncAlertsLabel', + { + defaultMessage: 'Sync alert status with case status', + } +); diff --git a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx index 148ad275b756e..be437073e693c 100644 --- a/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx +++ b/x-pack/plugins/security_solution/public/cases/components/user_action_tree/user_action_alert_comment_event.tsx @@ -9,7 +9,7 @@ import { EuiLink } from '@elastic/eui'; import { APP_ID } from '../../../../common/constants'; import { useKibana } from '../../../common/lib/kibana'; -import { getRuleDetailsUrl, useFormatUrl } from '../../../common/components/link_to'; +import { getRuleDetailsUrl } from '../../../common/components/link_to'; import { SecurityPageName } from '../../../app/types'; import { Alert } from '../case_view'; @@ -23,16 +23,15 @@ const AlertCommentEventComponent: React.FC = ({ alert }) => { const ruleName = alert?.rule?.name ?? null; const ruleId = alert?.rule?.id ?? null; const { navigateToApp } = useKibana().services.application; - const { formatUrl } = useFormatUrl(SecurityPageName.detections); const onLinkClick = useCallback( (ev: { preventDefault: () => void }) => { ev.preventDefault(); navigateToApp(`${APP_ID}:${SecurityPageName.detections}`, { - path: formatUrl(getRuleDetailsUrl(ruleId ?? '')), + path: getRuleDetailsUrl(ruleId ?? ''), }); }, - [ruleId, formatUrl, navigateToApp] + [ruleId, navigateToApp] ); return ruleId != null && ruleName != null ? ( diff --git a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx index f60993fc9aa02..bec1ab3dd4292 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/api.test.tsx @@ -384,6 +384,9 @@ describe('Case Configuration API', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; test('check url, method, signal', async () => { diff --git a/x-pack/plugins/security_solution/public/cases/containers/mock.ts b/x-pack/plugins/security_solution/public/cases/containers/mock.ts index 40312a8713783..f94fb189c90ce 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/mock.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/mock.ts @@ -76,6 +76,9 @@ export const basicCase: Case = { updatedAt: basicUpdatedAt, updatedBy: elasticUser, version: 'WzQ3LDFd', + settings: { + syncAlerts: true, + }, }; export const basicCasePost: Case = { diff --git a/x-pack/plugins/security_solution/public/cases/containers/types.ts b/x-pack/plugins/security_solution/public/cases/containers/types.ts index ec1eaa939fe31..a5c9c65dab62a 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/types.ts +++ b/x-pack/plugins/security_solution/public/cases/containers/types.ts @@ -11,6 +11,7 @@ import { CaseConnector, CommentRequest, CaseStatuses, + CaseAttributes, } from '../../../../case/common/api'; export { CaseConnector, ActionConnector } from '../../../../case/common/api'; @@ -63,6 +64,7 @@ export interface Case { updatedAt: string | null; updatedBy: ElasticUser | null; version: string; + settings: CaseAttributes['settings']; } export interface QueryParams { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx index 44166a14ad292..060ed787c7f4e 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_get_case.tsx @@ -74,6 +74,9 @@ export const initialData: Case = { updatedAt: null, updatedBy: null, version: '', + settings: { + syncAlerts: true, + }, }; export interface UseGetCase extends CaseState { diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx index c4363236a0977..8e8432d0d190c 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_post_case.test.tsx @@ -24,6 +24,9 @@ describe('usePostCase', () => { type: ConnectorTypes.none, fields: null, }, + settings: { + syncAlerts: true, + }, }; beforeEach(() => { jest.clearAllMocks(); diff --git a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx index c305399ee02d0..08333416d3c46 100644 --- a/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx +++ b/x-pack/plugins/security_solution/public/cases/containers/use_update_case.tsx @@ -19,7 +19,7 @@ import { Case } from './types'; export type UpdateKey = keyof Pick< CasePatchRequest, - 'connector' | 'description' | 'status' | 'tags' | 'title' + 'connector' | 'description' | 'status' | 'tags' | 'title' | 'settings' >; interface NewCaseState { diff --git a/x-pack/plugins/security_solution/public/cases/translations.ts b/x-pack/plugins/security_solution/public/cases/translations.ts index a79f7a3af18bf..fd217457f9e7d 100644 --- a/x-pack/plugins/security_solution/public/cases/translations.ts +++ b/x-pack/plugins/security_solution/public/cases/translations.ts @@ -256,3 +256,25 @@ export const IN_PROGRESS_CASES = i18n.translate( defaultMessage: 'In progress cases', } ); + +export const SYNC_ALERTS_SWITCH_LABEL_ON = i18n.translate( + 'xpack.securitySolution.case.settings.syncAlertsSwitchLabelOn', + { + defaultMessage: 'On', + } +); + +export const SYNC_ALERTS_SWITCH_LABEL_OFF = i18n.translate( + 'xpack.securitySolution.case.settings.syncAlertsSwitchLabelOff', + { + defaultMessage: 'Off', + } +); + +export const SYNC_ALERTS_HELP = i18n.translate( + 'xpack.securitySolution.components.create.syncAlertHelpText', + { + defaultMessage: + 'Enabling this option will sync the status of alerts in this case with the case status.', + } +); diff --git a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx index 0dcd29a2d965b..52ab414811cec 100644 --- a/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/alerts_viewer/index.tsx @@ -7,7 +7,7 @@ import React, { useEffect, useCallback, useMemo } from 'react'; import numeral from '@elastic/numeral'; import { DEFAULT_NUMBER_FORMAT } from '../../../../common/constants'; -import { useFullScreen } from '../../containers/use_full_screen'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { AlertsComponentsProps } from './types'; import { AlertsTable } from './alerts_table'; @@ -30,7 +30,7 @@ const AlertsViewComponent: React.FC = ({ startDate, }) => { const [defaultNumberFormat] = useUiSetting$(DEFAULT_NUMBER_FORMAT); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const getSubtitle = useCallback( (totalCount: number) => 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 d6b2efbe43053..4aa8361a0b8e4 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 @@ -36,7 +36,7 @@ import { import { inputsModel } from '../../store'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; import { ExitFullScreen } from '../exit_full_screen'; -import { useFullScreen } from '../../containers/use_full_screen'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { TimelineExpandedEvent, TimelineId } from '../../../../common/types/timeline'; import { GraphOverlay } from '../../../timelines/components/graph_overlay'; @@ -150,7 +150,7 @@ const EventsViewerComponent: React.FC = ({ graphEventId, }) => { const dispatch = useDispatch(); - const { globalFullScreen, timelineFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const columnsHeader = isEmpty(columns) ? defaultHeaders : columns; const kibana = useKibana(); const [isQueryLoading, setIsQueryLoading] = useState(false); @@ -286,7 +286,7 @@ const EventsViewerComponent: React.FC = ({ id={!resolverIsShowing(graphEventId) ? id : undefined} height={headerFilterGroup ? COMPACT_HEADER_HEIGHT : EVENTS_VIEWER_HEADER_HEIGHT} subtitle={utilityBar ? undefined : subtitle} - title={timelineFullScreen ? justTitle : titleWithExitFullScreen} + title={globalFullScreen ? titleWithExitFullScreen : justTitle} > {HeaderSectionContent} diff --git a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx index 2570a2b6d1f37..3272b0306f9c9 100644 --- a/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/events_viewer/index.tsx @@ -16,7 +16,7 @@ import { SubsetTimelineModel, TimelineModel } from '../../../timelines/store/tim import { Filter } from '../../../../../../../src/plugins/data/public'; import { EventsViewer } from './events_viewer'; import { InspectButtonContainer } from '../inspect'; -import { useFullScreen } from '../../containers/use_full_screen'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { SourcererScopeName } from '../../store/sourcerer/model'; import { useSourcererScope } from '../../containers/sourcerer'; import { EventDetailsFlyout } from './event_details_flyout'; @@ -78,7 +78,7 @@ const StatefulEventsViewerComponent: React.FC = ({ selectedPatterns, loading: isLoadingIndexPattern, } = useSourcererScope(scopeId); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); useEffect(() => { if (createTimeline != null) { diff --git a/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx b/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx index cd4740bc8c464..12e7df9a11302 100644 --- a/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exit_full_screen/index.tsx @@ -8,7 +8,7 @@ import { EuiButton, EuiWindowEvent } from '@elastic/eui'; import React, { useCallback } from 'react'; import styled from 'styled-components'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; import * as i18n from './translations'; @@ -17,7 +17,7 @@ const StyledEuiButton = styled(EuiButton)` `; export const ExitFullScreen: React.FC = () => { - const { globalFullScreen, setGlobalFullScreen } = useFullScreen(); + const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); const exitFullScreen = useCallback(() => { setGlobalFullScreen(false); diff --git a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx index 7e8c93e86376a..e8a17d78644df 100644 --- a/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/header_global/index.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { OutPortal } from 'react-reverse-portal'; import { navTabs } from '../../../app/home/home_navigations'; -import { useFullScreen } from '../../containers/use_full_screen'; +import { useGlobalFullScreen, useTimelineFullScreen } from '../../containers/use_full_screen'; import { SecurityPageName } from '../../../app/types'; import { getAppOverviewUrl } from '../link_to'; import { MlPopover } from '../ml_popover/ml_popover'; @@ -68,7 +68,8 @@ export const HeaderGlobal = React.memo( forwardRef( ({ hideDetectionEngine = false, isFixed = true }, ref) => { const { globalHeaderPortalNode } = useGlobalHeaderPortal(); - const { globalFullScreen, timelineFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); + const { timelineFullScreen } = useTimelineFullScreen(); const search = useGetUrlSearch(navTabs.overview); const { application, http } = useKibana().services; const { navigateToApp } = application; diff --git a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx index 23f9a8a6bce01..e1f6310644be0 100644 --- a/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/wrapper_page/index.tsx @@ -9,7 +9,7 @@ import React, { useEffect } from 'react'; import styled from 'styled-components'; import { CommonProps } from '@elastic/eui'; -import { useFullScreen } from '../../containers/use_full_screen'; +import { useGlobalFullScreen } from '../../containers/use_full_screen'; import { gutterTimeline } from '../../lib/helpers'; import { AppGlobalStyle } from '../page/index'; @@ -53,7 +53,7 @@ const WrapperPageComponent: React.FC = ({ noTimeline, ...otherProps }) => { - const { globalFullScreen, setGlobalFullScreen } = useFullScreen(); + const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); useEffect(() => { setGlobalFullScreen(false); // exit full screen mode on page load }, [setGlobalFullScreen]); diff --git a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx index b7938a5f3d755..577d7aa78e35c 100644 --- a/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/sourcerer/index.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useEffect, useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; import { useDispatch } from 'react-redux'; import { sourcererActions, sourcererSelectors } from '../../store/sourcerer'; @@ -19,7 +19,8 @@ export const useInitSourcerer = ( scopeId: SourcererScopeName.default | SourcererScopeName.detections = SourcererScopeName.default ) => { const dispatch = useDispatch(); - + const initialTimelineSourcerer = useRef(true); + const initialDetectionSourcerer = useRef(true); const { loading: loadingSignalIndex, isSignalIndexExists, signalIndexName } = useUserInfo(); const getConfigIndexPatternsSelector = useMemo( () => sourcererSelectors.configIndexPatternsSelector(), @@ -27,6 +28,12 @@ export const useInitSourcerer = ( ); const ConfigIndexPatterns = useDeepEqualSelector(getConfigIndexPatternsSelector); + const getSignalIndexNameSelector = useMemo( + () => sourcererSelectors.signalIndexNameSelector(), + [] + ); + const signalIndexNameSelector = useDeepEqualSelector(getSignalIndexNameSelector); + const getTimelineSelector = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const activeTimeline = useDeepEqualSelector((state) => getTimelineSelector(state, TimelineId.active) @@ -36,42 +43,71 @@ export const useInitSourcerer = ( useIndexFields(SourcererScopeName.timeline); useEffect(() => { - if (!loadingSignalIndex && signalIndexName != null) { + if (!loadingSignalIndex && signalIndexName != null && signalIndexNameSelector == null) { dispatch(sourcererActions.setSignalIndexName({ signalIndexName })); } - }, [dispatch, loadingSignalIndex, signalIndexName]); + }, [dispatch, loadingSignalIndex, signalIndexName, signalIndexNameSelector]); // Related to timeline useEffect(() => { if ( !loadingSignalIndex && signalIndexName != null && - (activeTimeline == null || (activeTimeline != null && activeTimeline.savedObjectId == null)) + signalIndexNameSelector == null && + (activeTimeline == null || + (activeTimeline != null && activeTimeline.savedObjectId == null)) && + initialTimelineSourcerer.current ) { + initialTimelineSourcerer.current = false; dispatch( sourcererActions.setSelectedIndexPatterns({ id: SourcererScopeName.timeline, selectedPatterns: [...ConfigIndexPatterns, signalIndexName], }) ); + } else if (signalIndexNameSelector != null && initialTimelineSourcerer.current) { + initialTimelineSourcerer.current = false; + dispatch( + sourcererActions.setSelectedIndexPatterns({ + id: SourcererScopeName.timeline, + selectedPatterns: [...ConfigIndexPatterns, signalIndexNameSelector], + }) + ); } - }, [activeTimeline, ConfigIndexPatterns, dispatch, loadingSignalIndex, signalIndexName]); + }, [ + activeTimeline, + ConfigIndexPatterns, + dispatch, + loadingSignalIndex, + signalIndexName, + signalIndexNameSelector, + ]); // Related to the detection page useEffect(() => { if ( scopeId === SourcererScopeName.detections && isSignalIndexExists && - signalIndexName != null + signalIndexName != null && + initialDetectionSourcerer.current ) { + initialDetectionSourcerer.current = false; dispatch( sourcererActions.setSelectedIndexPatterns({ id: scopeId, selectedPatterns: [signalIndexName], }) ); + } else if (signalIndexNameSelector != null && initialTimelineSourcerer.current) { + initialDetectionSourcerer.current = false; + dispatch( + sourcererActions.setSelectedIndexPatterns({ + id: scopeId, + selectedPatterns: [signalIndexNameSelector], + }) + ); } - }, [dispatch, isSignalIndexExists, scopeId, signalIndexName]); + }, [dispatch, isSignalIndexExists, scopeId, signalIndexName, signalIndexNameSelector]); }; export const useSourcererScope = (scope: SourcererScopeName = SourcererScopeName.default) => { diff --git a/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx b/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx index 8357a9d22739e..874005bf07428 100644 --- a/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx +++ b/x-pack/plugins/security_solution/public/common/containers/use_full_screen/index.tsx @@ -28,13 +28,20 @@ export const resetScroll = () => { }, 0); }; -export const useFullScreen = () => { +interface GlobalFullScreen { + globalFullScreen: boolean; + setGlobalFullScreen: (fullScreen: boolean) => void; +} + +interface TimelineFullScreen { + timelineFullScreen: boolean; + setTimelineFullScreen: (fullScreen: boolean) => void; +} + +export const useGlobalFullScreen = (): GlobalFullScreen => { const dispatch = useDispatch(); const globalFullScreen = useShallowEqualSelector(inputsSelectors.globalFullScreenSelector) ?? false; - const timelineFullScreen = - useShallowEqualSelector(inputsSelectors.timelineFullScreenSelector) ?? false; - const setGlobalFullScreen = useCallback( (fullScreen: boolean) => { if (fullScreen) { @@ -49,21 +56,31 @@ export const useFullScreen = () => { }, [dispatch] ); + const memoizedReturn = useMemo( + () => ({ + globalFullScreen, + setGlobalFullScreen, + }), + [globalFullScreen, setGlobalFullScreen] + ); + return memoizedReturn; +}; + +export const useTimelineFullScreen = (): TimelineFullScreen => { + const dispatch = useDispatch(); + const timelineFullScreen = + useShallowEqualSelector(inputsSelectors.timelineFullScreenSelector) ?? false; const setTimelineFullScreen = useCallback( (fullScreen: boolean) => dispatch(inputsActions.setFullScreen({ id: 'timeline', fullScreen })), [dispatch] ); - const memoizedReturn = useMemo( () => ({ - globalFullScreen, - setGlobalFullScreen, - setTimelineFullScreen, timelineFullScreen, + setTimelineFullScreen, }), - [globalFullScreen, setGlobalFullScreen, setTimelineFullScreen, timelineFullScreen] + [timelineFullScreen, setTimelineFullScreen] ); - return memoizedReturn; }; diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts index 3e47478b783eb..fcf7dfec0f2a4 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.test.ts @@ -20,6 +20,7 @@ describe('createInitialState', () => { { kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], configIndexPatterns: ['auditbeat-*', 'filebeat'], + signalIndexName: 'siem-signals-default', } ); @@ -32,6 +33,7 @@ describe('createInitialState', () => { { kibanaIndexPatterns: [{ id: '1234567890987654321', title: 'mock-kibana' }], configIndexPatterns: [], + signalIndexName: 'siem-signals-default', } ); diff --git a/x-pack/plugins/security_solution/public/common/store/reducer.ts b/x-pack/plugins/security_solution/public/common/store/reducer.ts index 8d528f4279955..f48bf31e62575 100644 --- a/x-pack/plugins/security_solution/public/common/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/common/store/reducer.ts @@ -34,7 +34,12 @@ export const createInitialState = ( { kibanaIndexPatterns, configIndexPatterns, - }: { kibanaIndexPatterns: KibanaIndexPatterns; configIndexPatterns: string[] } + signalIndexName, + }: { + kibanaIndexPatterns: KibanaIndexPatterns; + configIndexPatterns: string[]; + signalIndexName: string | null; + } ): PreloadedState => { const preloadedState: PreloadedState = { app: initialAppState, @@ -52,6 +57,7 @@ export const createInitialState = ( }, kibanaIndexPatterns, configIndexPatterns, + signalIndexName, }, }; return preloadedState; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx index 13be87846df80..dda35ad26a685 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/detection_engine.tsx @@ -37,7 +37,7 @@ import { DetectionEngineUserUnauthenticated } from './detection_engine_user_unau import * as i18n from './translations'; import { LinkButton } from '../../../common/components/links'; import { useFormatUrl } from '../../../common/components/link_to'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; import { Display } from '../../../hosts/pages/display'; import { showGlobalFilters } from '../../../timelines/components/timeline/helpers'; import { timelineSelectors } from '../../../timelines/store/timeline'; @@ -61,7 +61,7 @@ const DetectionEnginePageComponent = () => { const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { to, from, deleteQuery, setQuery } = useGlobalTime(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const [ { loading: userInfoLoading, diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx index 28c7805e968d6..3986e02b5b9b9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/details/index.tsx @@ -79,7 +79,7 @@ import { LinkButton } from '../../../../../common/components/links'; import { useFormatUrl } from '../../../../../common/components/link_to'; import { ExceptionsViewer } from '../../../../../common/components/exceptions/viewer'; import { DEFAULT_INDEX_PATTERN } from '../../../../../../common/constants'; -import { useFullScreen } from '../../../../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../../../../common/containers/use_full_screen'; import { Display } from '../../../../../hosts/pages/display'; import { ExceptionListTypeEnum, ExceptionListIdentifiers } from '../../../../../shared_imports'; import { useRuleAsync } from '../../../../containers/detection_engine/rules/use_rule_async'; @@ -178,7 +178,7 @@ const RuleDetailsPageComponent = () => { const mlCapabilities = useMlCapabilities(); const history = useHistory(); const { formatUrl } = useFormatUrl(SecurityPageName.detections); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); // TODO: Refactor license check + hasMlAdminPermissions to common check const hasMlPermissions = hasMlLicense(mlCapabilities) && hasMlAdminPermissions(mlCapabilities); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx index 58474f05bb2b9..7eef46a480707 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/details/index.tsx @@ -43,7 +43,7 @@ import { HostDetailsProps } from './types'; import { type } from './utils'; import { getHostDetailsPageFilters } from './helpers'; import { showGlobalFilters } from '../../../timelines/components/timeline/helpers'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; import { Display } from '../display'; import { timelineSelectors } from '../../../timelines/store/timeline'; import { TimelineId } from '../../../../common/types/timeline'; @@ -68,7 +68,7 @@ const HostDetailsComponent: React.FC = ({ detailName, hostDeta const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const capabilities = useMlCapabilities(); const kibana = useKibana(); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx index d54891ba573fd..52ec837a09eb6 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/hosts.tsx @@ -20,7 +20,7 @@ import { SiemNavigation } from '../../common/components/navigation'; import { HostsKpiComponent } from '../components/kpi_hosts'; import { SiemSearchBar } from '../../common/components/search_bar'; import { WrapperPage } from '../../common/components/wrapper_page'; -import { useFullScreen } from '../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../common/containers/use_full_screen'; import { useGlobalTime } from '../../common/containers/use_global_time'; import { TimelineId } from '../../../common/types/timeline'; import { LastEventIndexKey } from '../../../common/search_strategy'; @@ -66,7 +66,7 @@ const HostsComponent = () => { const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { to, from, deleteQuery, setQuery, isInitializing } = useGlobalTime(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const capabilities = useMlCapabilities(); const { uiSettings } = useKibana().services; const { tabName } = useParams<{ tabName: string }>(); diff --git a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx index e30071ec04f0c..1540ffd59f2de 100644 --- a/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx +++ b/x-pack/plugins/security_solution/public/hosts/pages/navigation/events_query_tab_body.tsx @@ -16,7 +16,7 @@ import { MatrixHistogramConfigs, } from '../../../common/components/matrix_histogram/types'; import { MatrixHistogram } from '../../../common/components/matrix_histogram'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { useGlobalFullScreen } from '../../../common/containers/use_full_screen'; import * as i18n from '../translations'; import { MatrixHistogramType } from '../../../../common/search_strategy/security_solution'; import { useManageTimeline } from '../../../timelines/components/manage_timeline'; @@ -62,7 +62,7 @@ const EventsQueryTabBodyComponent: React.FC = ({ }) => { const dispatch = useDispatch(); const { initializeTimeline } = useManageTimeline(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); useEffect(() => { initializeTimeline({ id: TimelineId.hostsPageEvents, diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts index d25588dabedc6..176f64c8bdcb0 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/models/advanced_policy_schema.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { i18n } from '@kbn/i18n'; + interface AdvancedPolicySchemaType { key: string; first_supported_version: string; @@ -14,302 +16,560 @@ interface AdvancedPolicySchemaType { export const AdvancedPolicySchema: AdvancedPolicySchemaType[] = [ { key: 'linux.advanced.agent.connection_delay', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.agent.connection_delay', + { + defaultMessage: + 'How long to wait for agent connectivity before sending first policy reply, in seconds. Default: 60.', + } + ), }, { key: 'linux.advanced.artifacts.global.base_url', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.artifacts.global.base_url', + { + defaultMessage: + 'Base URL from which to download global artifact manifests. Default: https://artifacts.security.elastic.co.', + } + ), }, { key: 'linux.advanced.artifacts.global.manifest_relative_url', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'linux.advanced.artifacts.global.ca_cert', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.artifacts.global.manifest_relative_url', + { + defaultMessage: + 'Relative URL from which to download global artifact manifests. Default: /downloads/endpoint/manifest/artifacts-.zip.', + } + ), }, { key: 'linux.advanced.artifacts.global.public_key', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.artifacts.global.public_key', + { + defaultMessage: + 'PEM-encoded public key used to verify the global artifact manifest signature.', + } + ), }, { key: 'linux.advanced.artifacts.global.interval', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'linux.advanced.artifacts.user.base_url', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'linux.advanced.artifacts.user.ca_cert', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.artifacts.global.interval', + { + defaultMessage: + 'Interval between global artifact manifest download attempts, in seconds. Default: 3600.', + } + ), }, { key: 'linux.advanced.artifacts.user.public_key', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'linux.advanced.artifacts.user.interval', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.artifacts.user.public_key', + { + defaultMessage: + 'PEM-encoded public key used to verify the user artifact manifest signature.', + } + ), }, { key: 'linux.advanced.elasticsearch.delay', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.elasticsearch.delay', + { + defaultMessage: 'Delay for sending events to Elasticsearch, in seconds. Default: 120.', + } + ), }, { key: 'linux.advanced.elasticsearch.tls.verify_peer', - first_supported_version: '7.11', - documentation: 'default is true', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.elasticsearch.tls.verify_peer', + { + defaultMessage: 'Whether to verify the certificates presented by the peer. Default: true.', + } + ), }, { key: 'linux.advanced.elasticsearch.tls.verify_hostname', - first_supported_version: '7.11', - documentation: 'default is true', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.elasticsearch.tls.verify_hostname', + { + defaultMessage: + "Whether to verify the hostname of the peer is what's in the certificate. Default: true.", + } + ), }, { key: 'linux.advanced.elasticsearch.tls.ca_cert', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'mac.advanced.agent.connection_delay', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.elasticsearch.tls.ca_cert', + { + defaultMessage: 'PEM-encoded certificate for Elasticsearch certificate authority.', + } + ), }, { - key: 'mac.advanced.artifacts.global.base_url', + key: 'linux.advanced.logging.file', first_supported_version: '7.11', - documentation: '', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.logging.file', + { + defaultMessage: + 'A supplied value will override the log level configured for logs that are saved to disk and streamed to Elasticsearch. It is recommended Fleet be used to change this logging in most circumstances. Allowed values are error, warning, info, debug, and trace.', + } + ), }, { - key: 'mac.advanced.artifacts.global.manifest_relative_url', + key: 'linux.advanced.logging.syslog', first_supported_version: '7.11', - documentation: '', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.linux.advanced.logging.syslog', + { + defaultMessage: + 'A supplied value will configure logging to syslog. Allowed values are error, warning, info, debug, and trace.', + } + ), }, { - key: 'mac.advanced.artifacts.global.ca_cert', - first_supported_version: '7.11', - documentation: '', + key: 'mac.advanced.agent.connection_delay', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.agent.connection_delay', + { + defaultMessage: + 'How long to wait for agent connectivity before sending first policy reply, in seconds. Default: 60.', + } + ), }, { - key: 'mac.advanced.artifacts.global.public_key', - first_supported_version: '7.11', - documentation: '', + key: 'mac.advanced.artifacts.global.base_url', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.artifacts.global.base_url', + { + defaultMessage: 'URL from which to download global artifact manifests.', + } + ), }, { - key: 'mac.advanced.artifacts.global.interval', - first_supported_version: '7.11', - documentation: '', + key: 'mac.advanced.artifacts.global.manifest_relative_url', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.artifacts.global.manifest_relative_url', + { + defaultMessage: + 'Relative URL from which to download global artifact manifests. Default: /downloads/endpoint/manifest/artifacts-.zip.', + } + ), }, { - key: 'mac.advanced.artifacts.user.base_url', - first_supported_version: '7.11', - documentation: '', + key: 'mac.advanced.artifacts.global.public_key', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.artifacts.global.public_key', + { + defaultMessage: + 'PEM-encoded public key used to verify the global artifact manifest signature.', + } + ), }, { - key: 'mac.advanced.artifacts.user.ca_cert', - first_supported_version: '7.11', - documentation: '', + key: 'mac.advanced.artifacts.global.interval', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.artifacts.global.interval', + { + defaultMessage: + 'Interval between global artifact manifest download attempts, in seconds. Default: 3600.', + } + ), }, { key: 'mac.advanced.artifacts.user.public_key', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'mac.advanced.artifacts.user.interval', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.artifacts.user.public_key', + { + defaultMessage: + 'PEM-encoded public key used to verify the user artifact manifest signature.', + } + ), }, { key: 'mac.advanced.elasticsearch.delay', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.elasticsearch.delay', + { + defaultMessage: 'Delay for sending events to Elasticsearch, in seconds. Default: 120.', + } + ), }, { key: 'mac.advanced.elasticsearch.tls.verify_peer', - first_supported_version: '7.11', - documentation: 'default is true', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.elasticsearch.tls.verify_peer', + { + defaultMessage: 'Whether to verify the certificates presented by the peer. Default: true.', + } + ), }, { key: 'mac.advanced.elasticsearch.tls.verify_hostname', - first_supported_version: '7.11', - documentation: 'default is true', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.elasticsearch.tls.verify_hostname', + { + defaultMessage: + "Whether to verify the hostname of the peer is what's in the certificate. Default: true.", + } + ), }, { key: 'mac.advanced.elasticsearch.tls.ca_cert', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.elasticsearch.tls.ca_cert', + { + defaultMessage: 'PEM-encoded certificate for Elasticsearch certificate authority.', + } + ), }, { - key: 'mac.advanced.malware.quarantine', + key: 'mac.advanced.logging.file', first_supported_version: '7.11', - documentation: '', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.logging.file', + { + defaultMessage: + 'A supplied value will override the log level configured for logs that are saved to disk and streamed to Elasticsearch. It is recommended Fleet be used to change this logging in most circumstances. Allowed values are error, warning, info, debug, and trace.', + } + ), }, { - key: 'mac.advanced.kernel.connect', + key: 'mac.advanced.logging.syslog', first_supported_version: '7.11', - documentation: '', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.logging.syslog', + { + defaultMessage: + 'A supplied value will configure logging to syslog. Allowed values are error, warning, info, debug, and trace.', + } + ), }, { - key: 'mac.advanced.kernel.harden', - first_supported_version: '7.11', - documentation: '', + key: 'mac.advanced.malware.quarantine', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.malware.quarantine', + { + defaultMessage: + 'Whether quarantine should be enabled when malware prevention is enabled. Default: true.', + } + ), + }, + { + key: 'mac.advanced.kernel.connect', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.kernel.connect', + { + defaultMessage: 'Whether to connect to the kernel driver. Default: true.', + } + ), }, { key: 'mac.advanced.kernel.process', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.kernel.process', + { + defaultMessage: + "A value of 'false' overrides other config settings that would enable kernel process events. Default: true.", + } + ), }, { key: 'mac.advanced.kernel.filewrite', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.kernel.filewrite', + { + defaultMessage: + "A value of 'false' overrides other config settings that would enable kernel file write events. Default: true.", + } + ), }, { key: 'mac.advanced.kernel.network', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.kernel.network', + { + defaultMessage: + "A value of 'false' overrides other config settings that would enable kernel network events. Default: true.", + } + ), + }, + { + key: 'mac.advanced.harden.self_protect', first_supported_version: '7.11', - documentation: '', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.mac.advanced.harden.self_protect', + { + defaultMessage: 'Enables self-protection on macOS. Default: true.', + } + ), }, { key: 'windows.advanced.agent.connection_delay', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.agent.connection_delay', + { + defaultMessage: + 'How long to wait for agent connectivity before sending first policy reply, in seconds. Default: 60.', + } + ), }, { key: 'windows.advanced.artifacts.global.base_url', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.artifacts.global.base_url', + { + defaultMessage: 'URL from which to download global artifact manifests.', + } + ), }, { key: 'windows.advanced.artifacts.global.manifest_relative_url', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'windows.advanced.artifacts.global.ca_cert', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.artifacts.global.manifest_relative_url', + { + defaultMessage: + 'Relative URL from which to download global artifact manifests. Default: /downloads/endpoint/manifest/artifacts-.zip.', + } + ), }, { key: 'windows.advanced.artifacts.global.public_key', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.artifacts.global.public_key', + { + defaultMessage: + 'PEM-encoded public key used to verify the global artifact manifest signature.', + } + ), }, { key: 'windows.advanced.artifacts.global.interval', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'windows.advanced.artifacts.user.base_url', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'windows.advanced.artifacts.user.ca_cert', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.artifacts.global.interval', + { + defaultMessage: + 'Interval between global artifact manifest download attempts, in seconds. Default: 3600.', + } + ), }, { key: 'windows.advanced.artifacts.user.public_key', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'windows.advanced.artifacts.user.interval', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.artifacts.user.public_key', + { + defaultMessage: + 'PEM-encoded public key used to verify the user artifact manifest signature.', + } + ), }, { key: 'windows.advanced.elasticsearch.delay', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.elasticsearch.delay', + { + defaultMessage: 'Delay for sending events to Elasticsearch, in seconds. Default: 120.', + } + ), }, { key: 'windows.advanced.elasticsearch.tls.verify_peer', - first_supported_version: '7.11', - documentation: 'default is true', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.elasticsearch.tls.verify_peer', + { + defaultMessage: 'Whether to verify the certificates presented by the peer. Default: true.', + } + ), }, { key: 'windows.advanced.elasticsearch.tls.verify_hostname', - first_supported_version: '7.11', - documentation: 'default is true', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.elasticsearch.tls.verify_hostname', + { + defaultMessage: + "Whether to verify the hostname of the peer is what's in the certificate. Default: true.", + } + ), }, { key: 'windows.advanced.elasticsearch.tls.ca_cert', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.elasticsearch.tls.ca_cert', + { + defaultMessage: 'PEM-encoded certificate for Elasticsearch certificate authority.', + } + ), }, { - key: 'windows.advanced.malware.quarantine', + key: 'windows.advanced.logging.file', first_supported_version: '7.11', - documentation: '', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.logging.file', + { + defaultMessage: + 'A supplied value will override the log level configured for logs that are saved to disk and streamed to Elasticsearch. It is recommended Fleet be used to change this logging in most circumstances. Allowed values are error, warning, info, debug, and trace.', + } + ), }, { - key: 'windows.advanced.ransomware.mbr', + key: 'windows.advanced.logging.debugview', first_supported_version: '7.11', - documentation: '', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.logging.debugview', + { + defaultMessage: + 'A supplied value will configure logging to Debugview (a Sysinternals tool). Allowed values are error, warning, info, debug, and trace.', + } + ), }, { - key: 'windows.advanced.ransomware.canary', - first_supported_version: '7.11', - documentation: '', + key: 'windows.advanced.malware.quarantine', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.malware.quarantine', + { + defaultMessage: + 'Whether quarantine should be enabled when malware prevention is enabled. Default: true.', + } + ), }, { key: 'windows.advanced.kernel.connect', - first_supported_version: '7.11', - documentation: '', - }, - { - key: 'windows.advanced.kernel.harden', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.connect', + { + defaultMessage: 'Whether to connect to the kernel driver. Default: true.', + } + ), }, { key: 'windows.advanced.kernel.process', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.process', + { + defaultMessage: + "A value of 'false' overrides other config settings that would enable kernel process events. Default: true.", + } + ), }, { key: 'windows.advanced.kernel.filewrite', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.filewrite', + { + defaultMessage: + "A value of 'false' overrides other config settings that would enable kernel file write events. Default: true.", + } + ), }, { key: 'windows.advanced.kernel.network', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.network', + { + defaultMessage: + "A value of 'false' overrides other config settings that would enable kernel network events. Default: true.", + } + ), }, { key: 'windows.advanced.kernel.fileopen', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.fileopen', + { + defaultMessage: + "A value of 'false' overrides other config settings that would enable kernel file open events. Default: true.", + } + ), }, { key: 'windows.advanced.kernel.asyncimageload', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.asyncimageload', + { + defaultMessage: + "A value of 'false' overrides other config settings that would enable kernel async image load events. Default: true.", + } + ), }, { key: 'windows.advanced.kernel.syncimageload', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.syncimageload', + { + defaultMessage: + "A value of 'false' overrides other config settings that would enable kernel sync image load events. Default: true.", + } + ), }, { key: 'windows.advanced.kernel.registry', - first_supported_version: '7.11', - documentation: '', + first_supported_version: '7.9', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.kernel.registry', + { + defaultMessage: + "A value of 'false' overrides other config settings that would enable kernel registry events. Default: true.", + } + ), + }, + { + key: 'windows.advanced.diagnostic.enabled', + first_supported_version: '7.11', + documentation: i18n.translate( + 'xpack.securitySolution.endpoint.policy.advanced.windows.advanced.diagnostic.enabled', + { + defaultMessage: + "A value of 'false' disables running diagnostic features on Endpoint. Default: true.", + } + ), }, ]; diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts index 7088f094ddcb4..77e975a46d37b 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts +++ b/x-pack/plugins/security_solution/public/management/pages/policy/store/policy_details/selectors.ts @@ -31,7 +31,43 @@ export const getPolicyDataForUpdate = ( ): NewPolicyData | Immutable => { // eslint-disable-next-line @typescript-eslint/naming-convention const { id, revision, created_by, created_at, updated_by, updated_at, ...newPolicy } = policy; - return newPolicy; + + // trim custom malware notification string + return { + ...newPolicy, + inputs: (newPolicy as Immutable).inputs.map((input) => ({ + ...input, + config: input.config && { + ...input.config, + policy: { + ...input.config.policy, + value: { + ...input.config.policy.value, + windows: { + ...input.config.policy.value.windows, + popup: { + ...input.config.policy.value.windows.popup, + malware: { + ...input.config.policy.value.windows.popup.malware, + message: input.config.policy.value.windows.popup.malware.message.trim(), + }, + }, + }, + mac: { + ...input.config.policy.value.mac, + popup: { + ...input.config.policy.value.mac.popup, + malware: { + ...input.config.policy.value.mac.popup.malware, + message: input.config.policy.value.mac.popup.malware.message.trim(), + }, + }, + }, + }, + }, + }, + })), + }; }; /** 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 bfa592b1f9c8e..e9c13b23834b1 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 @@ -293,7 +293,7 @@ describe('Policy Details', () => { policyView = render(); }); - it('malware popup and message customization options are shown', () => { + it('malware popup, message customization options and tooltip are shown', () => { // use query for finding stuff, if it doesn't find it, just returns null const userNotificationCheckbox = policyView.find( 'EuiCheckbox[data-test-subj="malwareUserNotificationCheckbox"]' @@ -301,8 +301,10 @@ describe('Policy Details', () => { const userNotificationCustomMessageTextArea = policyView.find( 'EuiTextArea[data-test-subj="malwareUserNotificationCustomMessage"]' ); + const tooltip = policyView.find('EuiIconTip'); expect(userNotificationCheckbox).toHaveLength(1); expect(userNotificationCustomMessageTextArea).toHaveLength(1); + expect(tooltip).toHaveLength(1); }); }); describe('when the subscription tier is gold or lower', () => { @@ -311,15 +313,17 @@ describe('Policy Details', () => { policyView = render(); }); - it('malware popup and message customization options are hidden', () => { + it('malware popup, message customization options, and tooltip are hidden', () => { const userNotificationCheckbox = policyView.find( 'EuiCheckbox[data-test-subj="malwareUserNotificationCheckbox"]' ); const userNotificationCustomMessageTextArea = policyView.find( 'EuiTextArea[data-test-subj="malwareUserNotificationCustomMessage"]' ); + const tooltip = policyView.find('EuiIconTip'); expect(userNotificationCheckbox).toHaveLength(0); expect(userNotificationCustomMessageTextArea).toHaveLength(0); + expect(tooltip).toHaveLength(0); }); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx index c78455aa8d990..d611c4102e8f8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/policy/view/policy_forms/protections/malware.tsx @@ -18,6 +18,9 @@ import { EuiText, EuiTextArea, htmlIdGenerator, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, } from '@elastic/eui'; import { cloneDeep } from 'lodash'; import { APP_ID } from '../../../../../../../common/constants'; @@ -193,7 +196,7 @@ export const MalwareProtections = React.memo(() => { if (policyDetailsConfig) { const newPayload = cloneDeep(policyDetailsConfig); for (const os of OSes) { - newPayload[os].popup[protection].message = event.target.value.trim(); + newPayload[os].popup[protection].message = event.target.value; } dispatch({ type: 'userChangedPolicyConfig', @@ -252,14 +255,37 @@ export const MalwareProtections = React.memo(() => { {isPlatinumPlus && userNotificationSelected && ( <> - -

- + + +

+ +

+
+
+ + + + + + + } /> -

-
+
+ ( const filters = useDeepEqualSelector(getGlobalFiltersQuerySelector); const { to, from, setQuery, isInitializing } = useGlobalTime(); - const { globalFullScreen } = useFullScreen(); + const { globalFullScreen } = useGlobalFullScreen(); const kibana = useKibana(); const { tabName } = useParams<{ tabName: string }>(); diff --git a/x-pack/plugins/security_solution/public/plugin.tsx b/x-pack/plugins/security_solution/public/plugin.tsx index 4f37b5b15d73a..0b5093ff50c39 100644 --- a/x-pack/plugins/security_solution/public/plugin.tsx +++ b/x-pack/plugins/security_solution/public/plugin.tsx @@ -41,6 +41,7 @@ import { APP_CASES_PATH, APP_PATH, DEFAULT_INDEX_KEY, + DETECTION_ENGINE_INDEX_URL, } from '../common/constants'; import { SecurityPageName } from './app/types'; @@ -435,6 +436,15 @@ export class Plugin implements IPlugin `; diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx index 749bc4b1f010d..6a51c7180587f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/active_timelines.tsx @@ -4,44 +4,49 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty } from '@elastic/eui'; -import React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiButtonEmpty, EuiHealth, EuiToolTip } from '@elastic/eui'; +import React, { useCallback, useMemo } from 'react'; import { useDispatch } from 'react-redux'; import { isEmpty } from 'lodash/fp'; import styled from 'styled-components'; +import { FormattedRelative } from '@kbn/i18n/react'; -import { TimelineType } from '../../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import { TimelineEventsCountBadge } from '../../../../common/hooks/use_timeline_events_count'; import { UNTITLED_TIMELINE, UNTITLED_TEMPLATE } from '../../timeline/properties/translations'; import { timelineActions } from '../../../store/timeline'; +import * as i18n from './translations'; const ButtonWrapper = styled(EuiFlexItem)` flex-direction: row; align-items: center; `; +const EuiHealthStyled = styled(EuiHealth)` + display: block; +`; + interface ActiveTimelinesProps { timelineId: string; + timelineStatus: TimelineStatus; timelineTitle: string; timelineType: TimelineType; isOpen: boolean; + updated?: number; } const StyledEuiButtonEmpty = styled(EuiButtonEmpty)` > span { padding: 0; - - > span { - display: flex; - flex-direction: row; - } } `; const ActiveTimelinesComponent: React.FC = ({ timelineId, + timelineStatus, timelineType, timelineTitle, + updated, isOpen, }) => { const dispatch = useDispatch(); @@ -57,17 +62,47 @@ const ActiveTimelinesComponent: React.FC = ({ ? UNTITLED_TEMPLATE : UNTITLED_TIMELINE; + const tooltipContent = useMemo(() => { + if (timelineStatus === TimelineStatus.draft) { + return <>{i18n.UNSAVED}; + } + return ( + <> + {i18n.AUTOSAVED}{' '} + + + ); + }, [timelineStatus, updated]); + return ( - {title} - {!isOpen && } + + + + + + + {title} + {!isOpen && ( + + + + )} + diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx index 368cb53eccc34..063e968a6c51a 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/index.tsx @@ -32,6 +32,8 @@ import { InspectButton } from '../../../../common/components/inspect'; import { ActiveTimelines } from './active_timelines'; import * as i18n from './translations'; import * as commonI18n from '../../timeline/properties/translations'; +import { getTimelineStatusByIdSelector } from './selectors'; +import { TimelineTabs } from '../../../store/timeline/model'; // to hide side borders const StyledPanel = styled(EuiPanel)` @@ -49,9 +51,27 @@ interface FlyoutHeaderPanelProps { const FlyoutHeaderPanelComponent: React.FC = ({ timelineId }) => { const dispatch = useDispatch(); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const { dataProviders, kqlQuery, title, timelineType, show } = useDeepEqualSelector((state) => + const { + activeTab, + dataProviders, + kqlQuery, + title, + timelineType, + status: timelineStatus, + updated, + show, + } = useDeepEqualSelector((state) => pick( - ['dataProviders', 'kqlQuery', 'title', 'timelineType', 'show'], + [ + 'activeTab', + 'dataProviders', + 'kqlQuery', + 'status', + 'title', + 'timelineType', + 'updated', + 'show', + ], getTimeline(state, timelineId) ?? timelineDefaults ) ); @@ -67,29 +87,33 @@ const FlyoutHeaderPanelComponent: React.FC = ({ timeline return ( - + {show && ( - - - + {activeTab === TimelineTabs.query && ( + + + + )} = ({ timelineId const TimelineDescription = React.memo(TimelineDescriptionComponent); const TimelineStatusInfoComponent: React.FC = ({ timelineId }) => { - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); + const getTimelineStatus = useMemo(() => getTimelineStatusByIdSelector(), []); const { status: timelineStatus, updated } = useDeepEqualSelector((state) => - pick(['status', 'updated'], getTimeline(state, timelineId) ?? timelineDefaults) + getTimelineStatus(state, timelineId) ); const isUnsaved = useMemo(() => timelineStatus === TimelineStatus.draft, [timelineStatus]); @@ -198,16 +222,16 @@ const TimelineStatusInfoComponent: React.FC = ({ timelineId } const TimelineStatusInfo = React.memo(TimelineStatusInfoComponent); const FlyoutHeaderComponent: React.FC = ({ timelineId }) => ( - + - + - + diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/header/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/selectors.ts new file mode 100644 index 0000000000000..634fa5a775f1a --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/header/selectors.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 { createSelector } from 'reselect'; + +import { TimelineStatus } from '../../../../../common/types/timeline'; +import { timelineSelectors } from '../../../store/timeline'; + +export const getTimelineStatusByIdSelector = () => + createSelector(timelineSelectors.selectTimeline, (timeline) => ({ + status: timeline?.status ?? TimelineStatus.draft, + updated: timeline?.updated ?? undefined, + })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx index 5d118b357c8ef..41e2a569f41bb 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.test.tsx @@ -40,6 +40,10 @@ jest.mock('../timeline', () => ({ describe('Flyout', () => { const state: State = mockGlobalState; const { storage } = createSecuritySolutionStorageMock(); + const props = { + onAppLeave: jest.fn(), + timelineId: 'test', + }; beforeEach(() => { mockDispatch.mockClear(); @@ -49,7 +53,7 @@ describe('Flyout', () => { test('it renders correctly against snapshot', () => { const wrapper = shallow( - + ); expect(wrapper.find('Flyout')).toMatchSnapshot(); @@ -58,7 +62,7 @@ describe('Flyout', () => { test('it renders the default flyout state as a bottom bar', () => { const wrapper = mount( - + ); @@ -79,7 +83,7 @@ describe('Flyout', () => { const wrapper = mount( - + ); @@ -91,7 +95,7 @@ describe('Flyout', () => { test('should call the onOpen when the mouse is clicked for rendering', () => { const wrapper = mount( - + ); diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx index a1e61b9fa4ae6..0636b76ef61bc 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/index.tsx @@ -4,14 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import React, { useEffect, useMemo } from 'react'; +import { useDispatch } from 'react-redux'; import styled from 'styled-components'; +import { AppLeaveHandler } from '../../../../../../../src/core/public'; +import { TimelineId, TimelineStatus } from '../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; +import { timelineActions } from '../../store/timeline'; +import { TimelineTabs } from '../../store/timeline/model'; import { FlyoutBottomBar } from './bottom_bar'; import { Pane } from './pane'; -import { timelineSelectors } from '../../store/timeline'; -import { useShallowEqualSelector } from '../../../common/hooks/use_selector'; -import { timelineDefaults } from '../../store/timeline/defaults'; +import { getTimelineShowStatusByIdSelector } from './selectors'; const Visible = styled.div<{ show?: boolean }>` visibility: ${({ show }) => (show ? 'visible' : 'hidden')}; @@ -21,14 +26,58 @@ Visible.displayName = 'Visible'; interface OwnProps { timelineId: string; + onAppLeave: (handler: AppLeaveHandler) => void; } -const FlyoutComponent: React.FC = ({ timelineId }) => { - const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); - const show = useShallowEqualSelector( - (state) => (getTimeline(state, timelineId) ?? timelineDefaults).show +const FlyoutComponent: React.FC = ({ timelineId, onAppLeave }) => { + const dispatch = useDispatch(); + const getTimelineShowStatus = useMemo(() => getTimelineShowStatusByIdSelector(), []); + const { show, status: timelineStatus, updated } = useDeepEqualSelector((state) => + getTimelineShowStatus(state, timelineId) ); + useEffect(() => { + onAppLeave((actions, nextAppId) => { + if (show) { + dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: false })); + } + // Confirm when the user has made any changes to a timeline + if ( + !(nextAppId ?? '').includes('securitySolution') && + timelineStatus === TimelineStatus.draft && + updated != null + ) { + const showSaveTimelineModal = () => { + dispatch(timelineActions.showTimeline({ id: TimelineId.active, show: true })); + dispatch( + timelineActions.setActiveTabTimeline({ + id: TimelineId.active, + activeTab: TimelineTabs.query, + }) + ); + dispatch( + timelineActions.toggleModalSaveTimeline({ + id: TimelineId.active, + showModalSaveTimeline: true, + }) + ); + }; + + return actions.confirm( + i18n.translate('xpack.securitySolution.timeline.unsavedWorkMessage', { + defaultMessage: 'Leave Timeline with unsaved work?', + }), + i18n.translate('xpack.securitySolution.timeline.unsavedWorkTitle', { + defaultMessage: 'Unsaved changes', + }), + showSaveTimelineModal + ); + } else { + return actions.default(); + } + }); + }, [dispatch, onAppLeave, show, timelineStatus, updated]); + return ( <> diff --git a/x-pack/plugins/security_solution/public/timelines/components/flyout/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/flyout/selectors.ts new file mode 100644 index 0000000000000..ca811afd164f6 --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/flyout/selectors.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createSelector } from 'reselect'; + +import { TimelineStatus } from '../../../../common/types/timeline'; +import { timelineSelectors } from '../../store/timeline'; + +export const getTimelineShowStatusByIdSelector = () => + createSelector(timelineSelectors.selectTimeline, (timeline) => ({ + status: timeline?.status ?? TimelineStatus.draft, + show: timeline?.show ?? false, + updated: timeline?.updated ?? undefined, + })); diff --git a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx index 3d5e548e726e5..ececded801b45 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/graph_overlay/index.test.tsx @@ -8,7 +8,10 @@ import { waitFor } from '@testing-library/react'; import { mount } from 'enzyme'; import React from 'react'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { + useGlobalFullScreen, + useTimelineFullScreen, +} from '../../../common/containers/use_full_screen'; import { mockTimelineModel, TestProviders } from '../../../common/mock'; import { TimelineId } from '../../../../common/types/timeline'; @@ -20,17 +23,20 @@ jest.mock('../../../common/hooks/use_selector', () => ({ })); jest.mock('../../../common/containers/use_full_screen', () => ({ - useFullScreen: jest.fn(), + useGlobalFullScreen: jest.fn(), + useTimelineFullScreen: jest.fn(), })); describe('GraphOverlay', () => { beforeEach(() => { - (useFullScreen as jest.Mock).mockReturnValue({ - timelineFullScreen: false, - setTimelineFullScreen: jest.fn(), + (useGlobalFullScreen as jest.Mock).mockReturnValue({ globalFullScreen: false, setGlobalFullScreen: jest.fn(), }); + (useTimelineFullScreen as jest.Mock).mockReturnValue({ + timelineFullScreen: false, + setTimelineFullScreen: jest.fn(), + }); }); describe('when used in an events viewer (i.e. in the Detections view, or the Host > Events view)', () => { @@ -51,12 +57,14 @@ describe('GraphOverlay', () => { }); test('it has a calculated width that makes room for the Timeline flyout button when isEventViewer is true in full screen mode', async () => { - (useFullScreen as jest.Mock).mockReturnValue({ - timelineFullScreen: false, - setTimelineFullScreen: jest.fn(), + (useGlobalFullScreen as jest.Mock).mockReturnValue({ globalFullScreen: true, // <-- true when an events viewer is in full screen mode setGlobalFullScreen: jest.fn(), }); + (useTimelineFullScreen as jest.Mock).mockReturnValue({ + timelineFullScreen: false, + setTimelineFullScreen: jest.fn(), + }); const wrapper = mount( @@ -89,12 +97,14 @@ describe('GraphOverlay', () => { }); test('it has 100% width when isEventViewer is false and the active timeline is in full screen mode', async () => { - (useFullScreen as jest.Mock).mockReturnValue({ - timelineFullScreen: true, // <-- true when the active timeline is in full screen mode - setTimelineFullScreen: jest.fn(), + (useGlobalFullScreen as jest.Mock).mockReturnValue({ globalFullScreen: false, setGlobalFullScreen: jest.fn(), }); + (useTimelineFullScreen as jest.Mock).mockReturnValue({ + timelineFullScreen: true, // <-- true when the active timeline is in full screen mode + setTimelineFullScreen: jest.fn(), + }); const wrapper = mount( 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 069f46c40e6af..8fac8fec0b61d 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 @@ -20,7 +20,10 @@ import styled from 'styled-components'; import { FULL_SCREEN } from '../timeline/body/column_headers/translations'; import { EXIT_FULL_SCREEN } from '../../../common/components/exit_full_screen/translations'; import { DEFAULT_INDEX_KEY, FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants'; -import { useFullScreen } from '../../../common/containers/use_full_screen'; +import { + useGlobalFullScreen, + useTimelineFullScreen, +} from '../../../common/containers/use_full_screen'; import { useDeepEqualSelector } from '../../../common/hooks/use_selector'; import { TimelineId } from '../../../../common/types/timeline'; import { timelineSelectors } from '../../store/timeline'; @@ -114,12 +117,8 @@ const GraphOverlayComponent: React.FC = ({ isEventViewer, timelineId } (state) => (getTimeline(state, timelineId) ?? timelineDefaults).graphEventId ); - const { - timelineFullScreen, - setTimelineFullScreen, - globalFullScreen, - setGlobalFullScreen, - } = useFullScreen(); + const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); + const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); const fullScreen = useMemo( () => isFullScreen({ globalFullScreen, timelineId, timelineFullScreen }), diff --git a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts index df12194e264de..37de75fd736af 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts +++ b/x-pack/plugins/security_solution/public/timelines/components/open_timeline/helpers.ts @@ -399,13 +399,15 @@ export const dispatchUpdateTimeline = (dispatch: Dispatch): DispatchUpdateTimeli to, ruleNote, }: UpdateTimeline): (() => void) => () => { - dispatch( - sourcererActions.initTimelineIndexPatterns({ - id: SourcererScopeName.timeline, - selectedPatterns: timeline.indexNames, - eventType: timeline.eventType, - }) - ); + if (!isEmpty(timeline.indexNames)) { + dispatch( + sourcererActions.initTimelineIndexPatterns({ + id: SourcererScopeName.timeline, + selectedPatterns: timeline.indexNames, + eventType: timeline.eventType, + }) + ); + } if ( timeline.status === TimelineStatus.immutable && timeline.timelineType === TimelineType.template diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx index 54cb4d5d14462..e3808514856e7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/body/column_headers/index.tsx @@ -27,7 +27,10 @@ import { } from '../../../../../common/components/drag_and_drop/helpers'; import { EXIT_FULL_SCREEN } from '../../../../../common/components/exit_full_screen/translations'; import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../../../common/constants'; -import { useFullScreen } from '../../../../../common/containers/use_full_screen'; +import { + useGlobalFullScreen, + useTimelineFullScreen, +} from '../../../../../common/containers/use_full_screen'; import { TimelineId } from '../../../../../../common/types/timeline'; import { OnSelectAll } from '../../events'; import { DEFAULT_ICON_BUTTON_WIDTH } from '../../helpers'; @@ -50,8 +53,16 @@ import * as i18n from './translations'; import { timelineActions } from '../../../../store/timeline'; const SortingColumnsContainer = styled.div` - .euiPopover .euiButtonEmpty .euiButtonContent .euiButtonEmpty__text { - display: none; + button { + color: ${({ theme }) => theme.eui.euiColorPrimary}; + } + + .euiPopover .euiButtonEmpty .euiButtonContent { + padding: 0; + + .euiButtonEmpty__text { + display: none; + } } `; @@ -115,12 +126,8 @@ export const ColumnHeadersComponent = ({ }: Props) => { const dispatch = useDispatch(); const [draggingIndex, setDraggingIndex] = useState(null); - const { - timelineFullScreen, - setTimelineFullScreen, - globalFullScreen, - setGlobalFullScreen, - } = useFullScreen(); + const { globalFullScreen, setGlobalFullScreen } = useGlobalFullScreen(); + const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); const toggleFullScreen = useCallback(() => { if (timelineId === TimelineId.active) { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx index 17d57b46d730c..c66a6e830ccf7 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/footer/index.tsx @@ -130,6 +130,9 @@ export const EventsCountComponent = ({ serverSideEventCount: number; footerText: string; }) => { + const totalCount = useMemo(() => (serverSideEventCount > 0 ? serverSideEventCount : 0), [ + serverSideEventCount, + ]); return (
- + - {serverSideEventCount} + {totalCount} {' '} {documentType} diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx index e9dc312ee8d19..18b2ebc2ec253 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.test.tsx @@ -3,13 +3,10 @@ * 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 { shallow, mount } from 'enzyme'; - +import { mount } from 'enzyme'; import { SaveTimelineButton } from './save_timeline_button'; -import { act } from '@testing-library/react-hooks'; - +import { TestProviders } from '../../../../common/mock'; jest.mock('react-redux', () => { const actual = jest.requireActual('react-redux'); return { @@ -17,60 +14,59 @@ jest.mock('react-redux', () => { useDispatch: jest.fn(), }; }); - +jest.mock('../../../../common/lib/kibana'); jest.mock('./title_and_description'); - describe('SaveTimelineButton', () => { const props = { + initialFocus: 'title' as const, timelineId: 'timeline-1', - showOverlay: false, toolTip: 'tooltip message', - toggleSaveTimeline: jest.fn(), - onSaveTimeline: jest.fn(), - updateTitle: jest.fn(), - updateDescription: jest.fn(), }; test('Show tooltip', () => { - const component = shallow(); + const component = mount( + + + + ); expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(true); }); - test('Hide tooltip', () => { - const testProps = { - ...props, - showOverlay: true, - }; - const component = mount(); + const component = mount( + + + + ); component.find('[data-test-subj="save-timeline-button-icon"]').first().simulate('click'); - - act(() => { - expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual( - false - ); - }); + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(false); }); - test('should show a button with pencil icon', () => { - const component = shallow(); - expect(component.find('[data-test-subj="save-timeline-button-icon"]').prop('iconType')).toEqual( - 'pencil' + const component = mount( + + + ); + expect( + component.find('[data-test-subj="save-timeline-button-icon"]').first().prop('iconType') + ).toEqual('pencil'); }); - test('should not show a modal when showOverlay equals false', () => { - const component = shallow(); + const component = mount( + + + + ); expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(false); }); - test('should show a modal when showOverlay equals true', () => { - const testProps = { - ...props, - showOverlay: true, - }; - const component = mount(); + const component = mount( + + + + ); + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(true); + expect(component.find('[data-test-subj="save-timeline-modal-comp"]').exists()).toEqual(false); component.find('[data-test-subj="save-timeline-button-icon"]').first().simulate('click'); - act(() => { - expect(component.find('[data-test-subj="save-timeline-modal"]').exists()).toEqual(true); - }); + expect(component.find('[data-test-subj="save-timeline-btn-tooltip"]').exists()).toEqual(false); + expect(component.find('[data-test-subj="save-timeline-modal-comp"]').exists()).toEqual(true); }); }); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx index f3bd4a88ca236..46898a8daaf89 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/save_timeline_button.tsx @@ -4,53 +4,69 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EuiButtonIcon, EuiOverlayMask, EuiModal, EuiToolTip } from '@elastic/eui'; - +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import React, { useCallback, useMemo, useState } from 'react'; -import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; +import { useDispatch } from 'react-redux'; +import { TimelineId } from '../../../../../common/types/timeline'; +import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; +import { timelineActions } from '../../../store/timeline'; +import { getTimelineSaveModalByIdSelector } from './selectors'; import { TimelineTitleAndDescription } from './title_and_description'; import { EDIT } from './translations'; export interface SaveTimelineComponentProps { + initialFocus: 'title' | 'description'; timelineId: string; toolTip?: string; } export const SaveTimelineButton = React.memo( - ({ timelineId, toolTip }) => { + ({ initialFocus, timelineId, toolTip }) => { + const dispatch = useDispatch(); + const getTimelineSaveModal = useMemo(() => getTimelineSaveModalByIdSelector(), []); + const show = useDeepEqualSelector((state) => getTimelineSaveModal(state, timelineId)); const [showSaveTimelineOverlay, setShowSaveTimelineOverlay] = useState(false); - const onToggleSaveTimeline = useCallback(() => { - setShowSaveTimelineOverlay((prevShowSaveTimelineOverlay) => !prevShowSaveTimelineOverlay); + + const closeSaveTimeline = useCallback(() => { + setShowSaveTimelineOverlay(false); + if (show) { + dispatch( + timelineActions.toggleModalSaveTimeline({ + id: TimelineId.active, + showModalSaveTimeline: false, + }) + ); + } + }, [dispatch, setShowSaveTimelineOverlay, show]); + + const openSaveTimeline = useCallback(() => { + setShowSaveTimelineOverlay(true); }, [setShowSaveTimelineOverlay]); const saveTimelineButtonIcon = useMemo( () => ( ), - [onToggleSaveTimeline] + [openSaveTimeline] ); - return showSaveTimelineOverlay ? ( + return (initialFocus === 'title' && show) || showSaveTimelineOverlay ? ( <> {saveTimelineButtonIcon} - - - - - + ) : ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/selectors.ts b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/selectors.ts new file mode 100644 index 0000000000000..8aa895e68dc7e --- /dev/null +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/selectors.ts @@ -0,0 +1,12 @@ +/* + * 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 { createSelector } from 'reselect'; + +import { timelineSelectors } from '../../../store/timeline'; + +export const getTimelineSaveModalByIdSelector = () => + createSelector(timelineSelectors.selectTimeline, (timeline) => timeline?.showSaveModal ?? false); diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx index cb31765bd9c37..2b8ec62199478 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.test.tsx @@ -8,8 +8,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { TimelineTitleAndDescription } from './title_and_description'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; -import { useCreateTimelineButton } from '../properties/use_create_timeline'; -import { TimelineType } from '../../../../../common/types/timeline'; +import { TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import * as i18n from './translations'; jest.mock('../../../../common/hooks/use_selector', () => ({ @@ -17,7 +16,7 @@ jest.mock('../../../../common/hooks/use_selector', () => ({ })); jest.mock('../properties/use_create_timeline', () => ({ - useCreateTimelineButton: jest.fn(), + useCreateTimeline: jest.fn(), })); jest.mock('react-redux', () => { @@ -31,8 +30,10 @@ jest.mock('react-redux', () => { describe('TimelineTitleAndDescription', () => { describe('save timeline', () => { const props = { + initialFocus: 'title' as const, + closeSaveTimeline: jest.fn(), + openSaveTimeline: jest.fn(), timelineId: 'timeline-1', - toggleSaveTimeline: jest.fn(), onSaveTimeline: jest.fn(), updateTitle: jest.fn(), updateDescription: jest.fn(), @@ -44,22 +45,18 @@ describe('TimelineTitleAndDescription', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: '', isSaving: true, - savedObjectId: null, + status: TimelineStatus.draft, title: 'my timeline', timelineType: TimelineType.default, }); - (useCreateTimelineButton as jest.Mock).mockReturnValue({ - getButton: mockGetButton, - }); }); afterEach(() => { (useDeepEqualSelector as jest.Mock).mockReset(); - (useCreateTimelineButton as jest.Mock).mockReset(); mockGetButton.mockClear(); }); - test('show proress bar while saving', () => { + test('show process bar while saving', () => { const component = shallow(); expect(component.find('[data-test-subj="progress-bar"]').exists()).toEqual(true); }); @@ -75,7 +72,7 @@ describe('TimelineTitleAndDescription', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: '', isSaving: true, - savedObjectId: null, + status: TimelineStatus.draft, title: 'my timeline', timelineType: TimelineType.template, }); @@ -108,6 +105,9 @@ describe('TimelineTitleAndDescription', () => { describe('update timeline', () => { const props = { + initialFocus: 'title' as const, + closeSaveTimeline: jest.fn(), + openSaveTimeline: jest.fn(), timelineId: 'timeline-1', toggleSaveTimeline: jest.fn(), onSaveTimeline: jest.fn(), @@ -121,22 +121,18 @@ describe('TimelineTitleAndDescription', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: 'xxxx', isSaving: true, - savedObjectId: '1234', + status: TimelineStatus.active, title: 'my timeline', timelineType: TimelineType.default, }); - (useCreateTimelineButton as jest.Mock).mockReturnValue({ - getButton: mockGetButton, - }); }); afterEach(() => { (useDeepEqualSelector as jest.Mock).mockReset(); - (useCreateTimelineButton as jest.Mock).mockReset(); mockGetButton.mockClear(); }); - test('show proress bar while saving', () => { + test('show process bar while saving', () => { const component = shallow(); expect(component.find('[data-test-subj="progress-bar"]').exists()).toEqual(true); }); @@ -152,7 +148,7 @@ describe('TimelineTitleAndDescription', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: 'xxxx', isSaving: true, - savedObjectId: '1234', + status: TimelineStatus.active, title: 'my timeline', timelineType: TimelineType.template, }); @@ -180,6 +176,9 @@ describe('TimelineTitleAndDescription', () => { describe('showWarning', () => { const props = { + initialFocus: 'title' as const, + closeSaveTimeline: jest.fn(), + openSaveTimeline: jest.fn(), timelineId: 'timeline-1', toggleSaveTimeline: jest.fn(), onSaveTimeline: jest.fn(), @@ -194,19 +193,15 @@ describe('TimelineTitleAndDescription', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: '', isSaving: true, - savedObjectId: null, + status: TimelineStatus.draft, title: 'my timeline', timelineType: TimelineType.default, showWarnging: true, }); - (useCreateTimelineButton as jest.Mock).mockReturnValue({ - getButton: mockGetButton, - }); }); afterEach(() => { (useDeepEqualSelector as jest.Mock).mockReset(); - (useCreateTimelineButton as jest.Mock).mockReset(); mockGetButton.mockClear(); }); @@ -217,34 +212,23 @@ describe('TimelineTitleAndDescription', () => { test('Show discardTimelineButton', () => { const component = shallow(); - expect(component.find('[data-test-subj="mock-discard-button"]').exists()).toEqual(true); - }); - - test('get discardTimelineButton with correct props', () => { - shallow(); - expect(mockGetButton).toBeCalledWith({ - title: i18n.DISCARD_TIMELINE, - outline: true, - iconType: '', - fill: false, - }); + expect(component.find('[data-test-subj="close-button"]').dive().text()).toEqual( + 'Discard Timeline' + ); }); test('get discardTimelineTemplateButton with correct props', () => { (useDeepEqualSelector as jest.Mock).mockReturnValue({ description: 'xxxx', isSaving: true, - savedObjectId: null, + status: TimelineStatus.draft, title: 'my timeline', timelineType: TimelineType.template, }); - shallow(); - expect(mockGetButton).toBeCalledWith({ - title: i18n.DISCARD_TIMELINE_TEMPLATE, - outline: true, - iconType: '', - fill: false, - }); + const component = shallow(); + expect(component.find('[data-test-subj="close-button"]').dive().text()).toEqual( + 'Discard Timeline Template' + ); }); test('Show saveButton', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx index 72e7778347f44..87d4fcdb7075f 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/header/title_and_description.tsx @@ -9,6 +9,8 @@ import { EuiFlexGroup, EuiFormRow, EuiFlexItem, + EuiOverlayMask, + EuiModal, EuiModalBody, EuiModalHeader, EuiSpacer, @@ -18,19 +20,23 @@ import { import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { useDispatch } from 'react-redux'; import styled from 'styled-components'; -import { TimelineType } from '../../../../../common/types/timeline'; + +import { TimelineId, TimelineStatus, TimelineType } from '../../../../../common/types/timeline'; import { useDeepEqualSelector } from '../../../../common/hooks/use_selector'; import { timelineActions, timelineSelectors } from '../../../../timelines/store/timeline'; import { TimelineInput } from '../../../store/timeline/actions'; import { Description, Name } from '../properties/helpers'; +import { NOTES_PANEL_WIDTH } from '../properties/notes_size'; import { TIMELINE_TITLE, DESCRIPTION, OPTIONAL } from '../properties/translations'; -import { useCreateTimelineButton } from '../properties/use_create_timeline'; +import { useCreateTimeline } from '../properties/use_create_timeline'; import * as i18n from './translations'; interface TimelineTitleAndDescriptionProps { - showWarning?: boolean; + closeSaveTimeline: () => void; + initialFocus: 'title' | 'description'; + openSaveTimeline: () => void; timelineId: string; - toggleSaveTimeline: () => void; + showWarning?: boolean; } const Wrapper = styled(EuiModalBody)` @@ -61,16 +67,18 @@ const usePrevious = (value: unknown) => { // the modal is used as a reminder for users to save / discard // the unsaved timeline / template export const TimelineTitleAndDescription = React.memo( - ({ timelineId, toggleSaveTimeline, showWarning }) => { + ({ closeSaveTimeline, initialFocus, openSaveTimeline, timelineId, showWarning }) => { // TODO: Refactor to use useForm() instead const [isFormSubmitted, setFormSubmitted] = useState(false); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const timeline = useDeepEqualSelector((state) => getTimeline(state, timelineId)); - - const { isSaving, savedObjectId, title, timelineType } = timeline; - + const { isSaving, status, title, timelineType } = timeline; const prevIsSaving = usePrevious(isSaving); const dispatch = useDispatch(); + const handleCreateNewTimeline = useCreateTimeline({ + timelineId: TimelineId.active, + timelineType: TimelineType.default, + }); const onSaveTimeline = useCallback( (args: TimelineInput) => dispatch(timelineActions.saveTimeline(args)), [dispatch] @@ -85,30 +93,30 @@ export const TimelineTitleAndDescription = React.memo - getButton({ - title: - timelineType === TimelineType.template - ? i18n.DISCARD_TIMELINE_TEMPLATE - : i18n.DISCARD_TIMELINE, - outline: true, - iconType: '', - fill: false, - }), - [getButton, timelineType] - ); + const handleCancel = useCallback(() => { + if (showWarning) { + handleCreateNewTimeline(); + } + closeSaveTimeline(); + }, [closeSaveTimeline, handleCreateNewTimeline, showWarning]); + + const closeModalText = useMemo(() => { + if (status === TimelineStatus.draft && showWarning) { + return timelineType === TimelineType.template + ? i18n.DISCARD_TIMELINE_TEMPLATE + : i18n.DISCARD_TIMELINE; + } + return i18n.CLOSE_MODAL; + }, [showWarning, status, timelineType]); useEffect(() => { if (isFormSubmitted && !isSaving && prevIsSaving) { - toggleSaveTimeline(); + closeSaveTimeline(); } - }, [isFormSubmitted, isSaving, prevIsSaving, toggleSaveTimeline]); + }, [isFormSubmitted, isSaving, prevIsSaving, closeSaveTimeline]); const modalHeader = - savedObjectId == null + status === TimelineStatus.draft ? timelineType === TimelineType.template ? i18n.SAVE_TIMELINE_TEMPLATE : i18n.SAVE_TIMELINE @@ -117,7 +125,7 @@ export const TimelineTitleAndDescription = React.memo - {isSaving && ( - - )} - {modalHeader} - - - {showWarning && ( + + + {isSaving && ( + + )} + {modalHeader} + + + {showWarning && ( + + + + + )} - - + + + + - )} - - - - - - - - - - - - - - - - {savedObjectId == null && showWarning ? ( - discardTimelineButton - ) : ( + + + + + + + + + - {i18n.CLOSE_MODAL} + {closeModalText} - )} - - - - {saveButtonTitle} - - - - - - + + + + {saveButtonTitle} + + + + + + + ); } ); 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 4e6bca7fd9625..5a1d2ef7a1800 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 @@ -21,7 +21,8 @@ import { useDeepEqualSelector, useShallowEqualSelector } from '../../../common/h import { activeTimeline } from '../../containers/active_timeline_context'; import * as i18n from './translations'; import { TabsContent } from './tabs_content'; -import { TimelineContainer } from './styles'; +import { HideShowContainer, TimelineContainer } from './styles'; +import { useTimelineFullScreen } from '../../../common/containers/use_full_screen'; const TimelineTemplateBadge = styled.div` background: ${({ theme }) => theme.eui.euiColorVis3_behindText}; @@ -55,6 +56,7 @@ const StatefulTimelineComponent: React.FC = ({ timelineId }) => { getTimeline(state, timelineId) ?? timelineDefaults ) ); + const { timelineFullScreen } = useTimelineFullScreen(); useEffect(() => { if (!savedObjectId) { @@ -79,7 +81,9 @@ const StatefulTimelineComponent: React.FC = ({ timelineId }) => { )} - + + + diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx index 6eb9286871b68..3a75922ab72bd 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.test.tsx @@ -105,19 +105,6 @@ describe('Description', () => { ).toEqual(i18n.DESCRIPTION_TOOL_TIP); }); - test('should not render textarea if isTextArea is false', () => { - const component = mount( - - - - ); - expect(component.find('[data-test-subj="timeline-description-textarea"]').exists()).toEqual( - false - ); - - expect(component.find('[data-test-subj="timeline-description-input"]').exists()).toEqual(true); - }); - test('should render textarea if isTextArea is true', () => { const testProps = { ...props, diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx index 673efa1857cb8..d17399a0fb180 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/helpers.tsx @@ -4,16 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - EuiBadge, - EuiButton, - EuiButtonIcon, - EuiFieldText, - EuiToolTip, - EuiTextArea, -} from '@elastic/eui'; +import { EuiBadge, EuiButton, EuiButtonIcon, EuiToolTip, EuiTextArea } from '@elastic/eui'; import { pick } from 'lodash/fp'; -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useMemo } from 'react'; import styled from 'styled-components'; import { useDispatch } from 'react-redux'; @@ -77,8 +70,8 @@ AddToFavoritesButtonComponent.displayName = 'AddToFavoritesButtonComponent'; export const AddToFavoritesButton = React.memo(AddToFavoritesButtonComponent); interface DescriptionProps { + autoFocus?: boolean; timelineId: string; - isTextArea?: boolean; disableAutoSave?: boolean; disableTooltip?: boolean; disabled?: boolean; @@ -86,8 +79,8 @@ interface DescriptionProps { export const Description = React.memo( ({ + autoFocus = false, timelineId, - isTextArea = false, disableAutoSave = false, disableTooltip = false, disabled = false, @@ -113,28 +106,21 @@ export const Description = React.memo( ); const inputField = useMemo( - () => - isTextArea ? ( - - ) : ( - - ), - [description, isTextArea, onDescriptionChanged, disabled] + () => ( + + ), + [autoFocus, description, onDescriptionChanged, disabled] ); + return ( {disableTooltip ? ( @@ -170,7 +156,6 @@ export const Name = React.memo( timelineId, }) => { const dispatch = useDispatch(); - const timelineNameRef = useRef(null); const getTimeline = useMemo(() => timelineSelectors.getTimelineByIdSelector(), []); const { title, timelineType } = useDeepEqualSelector((state) => @@ -185,15 +170,10 @@ export const Name = React.memo( [dispatch, timelineId, disableAutoSave] ); - useEffect(() => { - if (autoFocus && timelineNameRef && timelineNameRef.current) { - timelineNameRef.current.focus(); - } - }, [autoFocus]); - const nameField = useMemo( () => ( ( } spellCheck={true} value={title} - inputRef={timelineNameRef} /> ), - [handleChange, timelineType, title, disabled] + [autoFocus, handleChange, timelineType, title, disabled] ); return ( diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx index 7fab0374d791d..12845477e0f39 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/properties/use_create_timeline.tsx @@ -9,7 +9,7 @@ import { EuiButton, EuiButtonEmpty } from '@elastic/eui'; import { defaultHeaders } from '../body/column_headers/default_headers'; import { timelineActions } from '../../../store/timeline'; -import { useFullScreen } from '../../../../common/containers/use_full_screen'; +import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; import { TimelineId, TimelineType, @@ -34,7 +34,7 @@ export const useCreateTimeline = ({ timelineId, timelineType, closeGearMenu }: P [] ); const existingIndexNames = useDeepEqualSelector(existingIndexNamesSelector); - const { timelineFullScreen, setTimelineFullScreen } = useFullScreen(); + const { timelineFullScreen, setTimelineFullScreen } = useTimelineFullScreen(); const globalTimeRange = useDeepEqualSelector(inputsSelectors.globalTimeRangeSelector); const createTimeline = useCallback( ({ id, show }) => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx index 962e09d1a6237..d045cc6160c9c 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.test.tsx @@ -145,7 +145,7 @@ describe('Timeline', () => { expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(true); }); - test('it does NOT render the timeline table when the source is loading', () => { + test('it does render the timeline table when the source is loading with no events', () => { (useSourcererScope as jest.Mock).mockReturnValue({ browserFields: {}, docValueFields: [], @@ -159,7 +159,8 @@ describe('Timeline', () => { ); - expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="events"]').exists()).toEqual(false); }); test('it does NOT render the timeline table when start is empty', () => { @@ -169,7 +170,8 @@ describe('Timeline', () => { ); - expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="events"]').exists()).toEqual(false); }); test('it does NOT render the timeline table when end is empty', () => { @@ -179,7 +181,8 @@ describe('Timeline', () => { ); - expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(false); + expect(wrapper.find('[data-test-subj="events-table"]').exists()).toEqual(true); + expect(wrapper.find('[data-test-subj="events"]').exists()).toEqual(false); }); test('it does NOT render the paging footer when you do NOT have any data providers', () => { diff --git a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx index 8da3c257a5db8..e93d23a816911 100644 --- a/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx +++ b/x-pack/plugins/security_solution/public/timelines/components/timeline/query_tab_content/index.tsx @@ -23,7 +23,7 @@ import deepEqual from 'fast-deep-equal'; import { InPortal } from 'react-reverse-portal'; import { timelineActions, timelineSelectors } from '../../../store/timeline'; -import { Direction } from '../../../../../common/search_strategy'; +import { Direction, TimelineItem } from '../../../../../common/search_strategy'; import { useTimelineEvents } from '../../../containers/index'; import { useKibana } from '../../../../common/lib/kibana'; import { defaultHeaders } from '../body/column_headers/default_headers'; @@ -48,6 +48,8 @@ import { useTimelineEventsCountPortal } from '../../../../common/hooks/use_timel import { TimelineModel } from '../../../../timelines/store/timeline/model'; import { EventDetails } from '../event_details'; import { TimelineDatePickerLock } from '../date_picker_lock'; +import { HideShowContainer } from '../styles'; +import { useTimelineFullScreen } from '../../../../common/containers/use_full_screen'; import { activeTimeline } from '../../../containers/active_timeline_context'; import { ToggleExpandedEvent } from '../../../store/timeline/actions'; @@ -143,6 +145,8 @@ interface OwnProps { timelineId: string; } +const EMPTY_EVENTS: TimelineItem[] = []; + export type Props = OwnProps & PropsFromRedux; export const QueryTabContentComponent: React.FC = ({ @@ -168,6 +172,7 @@ export const QueryTabContentComponent: React.FC = ({ updateEventTypeAndIndexesName, }) => { const { timelineEventsCountPortalNode } = useTimelineEventsCountPortal(); + const { timelineFullScreen } = useTimelineFullScreen(); const { browserFields, docValueFields, @@ -200,6 +205,11 @@ export const QueryTabContentComponent: React.FC = ({ [browserFields, dataProviders, esQueryConfig, filters, indexPattern, kqlMode, kqlQuery] ); + const isBlankTimeline: boolean = useMemo( + () => isEmpty(dataProviders) && isEmpty(filters) && isEmpty(kqlQuery.query), + [dataProviders, filters, kqlQuery] + ); + const canQueryTimeline = useMemo( () => combinedQueries != null && @@ -287,67 +297,66 @@ export const QueryTabContentComponent: React.FC = ({ /> - - - - - - - + + + + + + + + + +
+ + + +
+ + -
-
-
- - - -
- - +
+ + + + - - - {canQueryTimeline ? ( - - - - - + + + {!isBlankTimeline && (