[Security Solution] Decompose Timelines TGrid component and moved to security_solution (#140151)

Resolves [#143152](https://github.com/elastic/kibana/issues/143152)
### Observability changes
This changes is a result of removal some types from `timelines` plugin:
- cleaned up timelines plugin related types, 
- replaced `Pick<ActionProps,'data' | 'eventId' | 'ecsData' |
'setEventsDeleted' >` with the props which were actually used:
  
    ```
  data: TimelineNonEcsData[];
    ecsData: Ecs;
    eventId: string;
  ```
In this PR we still have references to `@kbn/timelines-plugin`, which
needs to be changed later.
Threat Hunting team are going to think about replacing
`TimelineNonEcsData` with the other type definition (maybe
`NonEcsData`?) and moving `Ecs` type to the non `timelines` related
plugin/package.

### Security Solution changes
Before the current PR changes the components dependencies around `TGrid`
looked like the image below:
<img width="848" alt="Screen Shot 2022-11-29 at 6 16 14 AM"
src="https://user-images.githubusercontent.com/55110838/204663019-664431fb-f360-4a11-b395-6fa54c35dd6d.png">
After decomposition the `timelines` plugin hosted TGrid HOC and moving
all the data tables related sub-components to `security_solution` plugin
the new components architecture got the next shape:
<img width="842" alt="Screen Shot 2022-11-29 at 6 14 41 AM"
src="https://user-images.githubusercontent.com/55110838/204663068-40897f18-1485-4b59-a71b-ce09e660f7db.png">
`security_solution` plugin changes includes the next things:
- Moved some data table and actions types to
`x-pack/plugins/security_solution/common/types`, which is widely used
across the related components.
- Due to the movement of the data table with the store to from
`timeline` plugin to `security_solution` many test files which had the
reference to `tGridReducer` now cleaned up from the unnecessary logic:
```
- import { tGridReducer } from '@kbn/timelines-plugin/public';
```

and `TableState` references was replaced with the next changes:
```
- import type { TableState } from '@kbn/timelines-plugin/public';
+ import type { TableState } from '../common/store/data_table/types';
```
- Replaced `tGridActions` with `dataTableActions` name.
- Moved `control_columns` to `security_solution` common plugin
components: `RowCheckBox`, `HeaderCheckBox` and
`transformControlColumns`:
`RowActionComponent` moved from `timelines` plugin to
`x-pack/plugins/security_solution/public/common/components/control_columns/row_action`
without changes.
`transformControlColumns` moved from timelines plugin to
`x-pack/plugins/security_solution/public/common/components/control_columns/transform_control_columns.tsx`.
Removed not used property `hasAlertsCrudPermissions`, added unit test.
<img width="1222" alt="Screen Shot 2022-11-29 at 8 59 42 PM"
src="https://user-images.githubusercontent.com/55110838/204711499-9f90fee2-3c2f-4ff6-af28-c324ab1840d8.png">

- Many translation changes as a result of the owner plugin change:
 ```
 - i18n.translate('xpack.timelines....', {
 + i18n.translate('xpack.securitySolution....', {
```
- Moved `useDraggableKeyboardWrapper` to security_solution, added reference to `useAddToTimeline`, by using timelines plugin with kibana services. Added unit tests. 
<img width="1112" alt="Screen Shot 2022-11-30 at 9 06 42 AM" src="https://user-images.githubusercontent.com/55110838/204862298-bcd50a52-dbf7-480b-bf13-8e48d6835746.png">

- Replaced the next references:
```
- type: 'x-pack/timelines/t-grid/UPDATE_COLUMN_WIDTH',
+ type: 'x-pack/security_solution/data-table/UPDATE_COLUMN_WIDTH',
```

```
- type: 'x-pack/timelines/t-grid/REMOVE_COLUMN',
+ type: 'x-pack/security_solution/data-table/REMOVE_COLUMN',
```
- moved TGrid store previously hosted in timeline plugin  to `security_solution` as `data_table` store:
<img width="1109" alt="Screen Shot 2022-11-29 at 9 24 08 PM" src="https://user-images.githubusercontent.com/55110838/204714668-257a9c50-d722-4a6d-9214-f3ef8a14d0d2.png">

- Migrated TGrid `BodyComponent` to `DataTableComponent`
`x-pack/plugins/security_solution/public/common/components/data_table/index.tsx`
Removed some unused properties: `hasAlertsCrudPermissions, appId, getRowRenderer, isEventViewer, tableView, totalSelectAllAlerts, trailingControlColumns`. Current DataTableComponent is a subset of the previous BodyComponent, which includes only table related functionality:
<img width="1028" alt="Screen Shot 2022-11-30 at 10 44 35 AM" src="https://user-images.githubusercontent.com/55110838/204882561-0950b9ce-5a9f-4bdb-b38f-6ff742fc3f92.png">

- Renamed `TimelineExpandedDetail` to `ExpandedDetail` to make the type more generic for usage.
- BulkActions related changes includes:
<img width="1288" alt="Screen Shot 2022-11-29 at 9 13 32 PM" src="https://user-images.githubusercontent.com/55110838/204713196-409f3d5e-f752-4fe9-9ae9-e752514cbf99.png">

`AlertBulkActionsComponent` moved from timelines plugin to `x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/alert_bulk_actions.tsx`, just renaming changes.

Added `x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/types.ts` to consolidate types
`useBulkActionItems` moved from timelines plugin to `x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_bulk_action_items.tsx`. Changed links, renamed `AlertsStatus` to `AlertWorkflowStatus`, removed `in-progress` case handling.

`useUpdateAlertsStatus` moved from timelines plugin to `x-pack/plugins/security_solution/public/common/components/toolbar/bulk_actions/use_update_alerts.ts`. Cleaned up the code from handling Observability API.
- Updated `x-pack/plugins/security_solution/public/common/lib/kuery/index.ts` with the actual implementations of 
```
  convertKueryToDslFilter,
  convertKueryToElasticSearchQuery,
  convertToBuildEsQuery,
  escapeKuery,
  escapeQueryValue,
  combineQueries,
```
instead of referencing timelines plugin.
- Moved `EventRenderedView` component to security_solution common components. Later planning to make it as a package.
- `EventsViewer` component became the stateful component which is responsible for the data representation managing. Some part from TGridIntegratedComponent and BodyComponent was merged under its logic:
<img width="1052" alt="Screen Shot 2022-11-30 at 6 22 22 PM" src="https://user-images.githubusercontent.com/55110838/204950708-a8875acd-eb62-4df5-8ac4-613a0a571de6.png">
<img width="242" alt="Screen Shot 2022-11-30 at 6 24 06 PM" src="https://user-images.githubusercontent.com/55110838/204950819-bca194a4-4309-4cb4-a2ba-0176e9fe6c65.png">

- Moved header actions  to common components `x-pack/plugins/security_solution/public/common/components/header_actions`
- Renamed component `AlertCount` to `UnitCount`.
- Moved to `security_solution` configuration for `APM_USER_INTERACTIONS`
- changes `createStore` interface by using the direct reference to `dataTableReducer` instead of passing down it's value through the params.
### Timeline plugin changes
- cleaned up timeline plugin interface by removing:
```
getTGrid: <T extends TGridType = 'embedded'>(
    props: GetTGridProps<T>
  ) => ReactElement<GetTGridProps<T>>;
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
getTGridReducer: () => any;
getUseDraggableKeyboardWrapper: () => (
    props: UseDraggableKeyboardWrapperProps
  ) => UseDraggableKeyboardWrapper;
```
- renamed embedded store
```
- setTGridEmbeddedStore: (store: Store) => void;
+ setTimelineEmbeddedStore: (store: Store) => void;
```
- removed dependency to triggers_actions_ui plugin
- removed duplicated components and types with `security_solution`: 
```
TruncatableText
SubtitleComponent
EventsCountComponent
PopoverRowItems
PagingControlComponent
FooterComponent
SortIndicator
SortNumber
RowRendererContainer
plainRowRenderer
getColumnRenderer
StatefulRowRenderer
getMappedNonEcsValue
InspectButtonComponent
TGridCellAction
useMountAppended
tgrid store
```

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Yuliia Naumenko 2022-12-06 07:26:55 -08:00 committed by GitHub
parent 3e499922ff
commit f1dc15ae4b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
338 changed files with 4630 additions and 12500 deletions

View file

@ -60,7 +60,6 @@ describe('ObservabilityActions component', () => {
},
data: inventoryThresholdAlert as unknown as TimelineNonEcsData[],
observabilityRuleTypeRegistry: createObservabilityRuleTypeRegistryMock(),
setEventsDeleted: jest.fn(),
setFlyoutAlert: jest.fn(),
id: pageId,
};

View file

@ -18,7 +18,8 @@ import React, { useMemo, useState, useCallback } from 'react';
import { CaseAttachmentsWithoutOwner } from '@kbn/cases-plugin/public';
import { CommentType } from '@kbn/cases-plugin/common';
import type { ActionProps } from '@kbn/timelines-plugin/common';
import { Ecs } from '@kbn/timelines-plugin/common/ecs';
import { TimelineNonEcsData } from '@kbn/timelines-plugin/common';
import { isAlertDetailsEnabledPerApp } from '../../../utils/is_alert_details_enabled';
import { useKibana } from '../../../utils/kibana_react';
import { useGetUserCasesPermissions } from '../../../hooks/use_get_user_cases_permissions';
@ -32,15 +33,15 @@ import { ObservabilityRuleTypeRegistry } from '../../..';
import { ALERT_DETAILS_PAGE_ID } from '../../alert_details/types';
import { ConfigSchema } from '../../../plugin';
export type ObservabilityActionsProps = Pick<
ActionProps,
'data' | 'eventId' | 'ecsData' | 'setEventsDeleted'
> & {
export interface ObservabilityActionsProps {
data: TimelineNonEcsData[];
ecsData: Ecs;
eventId: string;
setFlyoutAlert: React.Dispatch<React.SetStateAction<TopAlert | undefined>>;
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry;
id?: string;
config: ConfigSchema;
};
}
export function ObservabilityActions({
data,

View file

@ -17,7 +17,6 @@ const buildData = (alerts: EcsFieldsResponse): ObservabilityActionsProps['data']
[]
);
};
const fakeSetEventsDeleted = () => [];
export const getRowActions = (
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry,
config: ConfigSchema
@ -35,7 +34,6 @@ export const getRowActions = (
ecsData={{ _id: alert._id, _index: alert._index }}
id={id}
observabilityRuleTypeRegistry={observabilityRuleTypeRegistry}
setEventsDeleted={fakeSetEventsDeleted}
setFlyoutAlert={setFlyoutAlert}
config={config}
/>

View file

@ -22,4 +22,7 @@ export type SignalEcsAAD = Exclude<SignalEcs, 'rule' | 'status'> & {
severity?: string[];
building_block_type?: string[];
workflow_status?: string[];
suppression?: {
docs_count: string[];
};
};

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TimelineItem } from '../../search_strategy';
export interface CustomBulkAction {
key: string;
label: string;
disableOnQuery?: boolean;
disabledLabel?: string;
onClick: (items?: TimelineItem[]) => void;
['data-test-subj']?: string;
}
export type CustomBulkActionProp = Omit<CustomBulkAction, 'onClick'> & {
onClick: (eventIds: string[]) => void;
};

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import * as runtimeTypes from 'io-ts';
export enum Direction {
asc = 'asc',
desc = 'desc',
}
export type SortDirectionTable = 'none' | 'asc' | 'desc' | Direction;
export interface SortColumnTable {
columnId: string;
columnType: string;
esTypes?: string[];
sortDirection: SortDirectionTable;
}
export type { TableById } from '../../../public/common/store/data_table/types';
export enum TableId {
usersPageEvents = 'users-page-events',
hostsPageEvents = 'hosts-page-events',
networkPageEvents = 'network-page-events',
hostsPageSessions = 'hosts-page-sessions-v2', // the v2 is to cache bust localstorage settings as default columns were reworked.
alertsOnRuleDetailsPage = 'alerts-rules-details-page',
alertsOnAlertsPage = 'alerts-page',
test = 'table-test', // Reserved for testing purposes
alternateTest = 'alternateTest',
rulePreview = 'rule-preview',
kubernetesPageSessions = 'kubernetes-page-sessions',
}
const TableIdLiteralRt = runtimeTypes.union([
runtimeTypes.literal(TableId.usersPageEvents),
runtimeTypes.literal(TableId.hostsPageEvents),
runtimeTypes.literal(TableId.networkPageEvents),
runtimeTypes.literal(TableId.hostsPageSessions),
runtimeTypes.literal(TableId.alertsOnRuleDetailsPage),
runtimeTypes.literal(TableId.alertsOnAlertsPage),
runtimeTypes.literal(TableId.test),
runtimeTypes.literal(TableId.rulePreview),
runtimeTypes.literal(TableId.kubernetesPageSessions),
]);
export type TableIdLiteral = runtimeTypes.TypeOf<typeof TableIdLiteralRt>;

View file

@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FlowTargetSourceDest } from '../../search_strategy';
import type { TimelineTabs } from '../timeline';
type EmptyObject = Record<string | number, never>;
export type ExpandedEventType =
| {
panelView?: 'eventDetail';
params?: {
eventId: string;
indexName: string;
refetch?: () => void;
};
}
| EmptyObject;
export type ExpandedHostType =
| {
panelView?: 'hostDetail';
params?: {
hostName: string;
};
}
| EmptyObject;
export type ExpandedNetworkType =
| {
panelView?: 'networkDetail';
params?: {
ip: string;
flowTarget: FlowTargetSourceDest;
};
}
| EmptyObject;
export type ExpandedUserType =
| {
panelView?: 'userDetail';
params?: {
userName: string;
};
}
| EmptyObject;
export type ExpandedDetailType =
| ExpandedEventType
| ExpandedHostType
| ExpandedNetworkType
| ExpandedUserType;
export type ExpandedDetailTimeline = {
[tab in TimelineTabs]?: ExpandedDetailType;
};
export type ExpandedDetail = Partial<Record<string, ExpandedDetailType>>;

View file

@ -0,0 +1,175 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
EuiDataGridCellValueElementProps,
EuiDataGridColumn,
EuiDataGridColumnCellActionProps,
EuiDataGridControlColumn,
} from '@elastic/eui';
import type { IFieldSubType } from '@kbn/es-query';
import type { FieldBrowserOptions } from '@kbn/triggers-actions-ui-plugin/public';
import type { ComponentType, JSXElementConstructor, ReactNode } from 'react';
import type { OnRowSelected, SetEventsDeleted, SetEventsLoading } from '..';
import type { Ecs } from '../../ecs';
import type { BrowserFields, TimelineNonEcsData } from '../../search_strategy';
import type { SortColumnTable } from '../data_table';
export type ColumnHeaderType = 'not-filtered' | 'text-filter';
/** Uniquely identifies a column */
export type ColumnId = string;
/**
* A `DataTableCellAction` function accepts `data`, where each row of data is
* represented as a `TimelineNonEcsData[]`. For example, `data[0]` would
* contain a `TimelineNonEcsData[]` with the first row of data.
*
* A `DataTableCellAction` returns a function that has access to all the
* `EuiDataGridColumnCellActionProps`, _plus_ access to `data`,
* which enables code like the following example to be written:
*
* Example:
* ```
* ({ data }: { data: TimelineNonEcsData[][] }) => ({ rowIndex, columnId, Component }) => {
* const value = getMappedNonEcsValue({
* data: data[rowIndex], // access a specific row's values
* fieldName: columnId,
* });
*
* return (
* <Component onClick={() => alert(`row ${rowIndex} col ${columnId} has value ${value}`)} iconType="heart">
* {'Love it'}
* </Component>
* );
* };
* ```
*/
export type DataTableCellAction = ({
browserFields,
data,
ecsData,
header,
pageSize,
scopeId,
closeCellPopover,
}: {
browserFields: BrowserFields;
/** each row of data is represented as one TimelineNonEcsData[] */
data: TimelineNonEcsData[][];
ecsData: Ecs[];
header?: ColumnHeaderOptions;
pageSize: number;
scopeId: string;
closeCellPopover?: () => void;
}) => (props: EuiDataGridColumnCellActionProps) => ReactNode;
/** The specification of a column header */
export type ColumnHeaderOptions = Pick<
EuiDataGridColumn,
| 'actions'
| 'defaultSortDirection'
| 'display'
| 'displayAsText'
| 'id'
| 'initialWidth'
| 'isSortable'
| 'schema'
> & {
aggregatable?: boolean;
dataTableCellActions?: DataTableCellAction[];
category?: string;
columnHeaderType: ColumnHeaderType;
description?: string | null;
esTypes?: string[];
example?: string | number | null;
format?: string;
linkField?: string;
placeholder?: string;
subType?: IFieldSubType;
type?: string;
};
export interface HeaderActionProps {
width: number;
browserFields: BrowserFields;
columnHeaders: ColumnHeaderOptions[];
fieldBrowserOptions?: FieldBrowserOptions;
isEventViewer?: boolean;
isSelectAllChecked: boolean;
onSelectAll: ({ isSelected }: { isSelected: boolean }) => void;
showEventsSelect: boolean;
showSelectAllCheckbox: boolean;
sort: SortColumnTable[];
tabType: string;
timelineId: string;
}
export type HeaderCellRender = ComponentType | ComponentType<HeaderActionProps>;
type GenericActionRowCellRenderProps = Pick<
EuiDataGridCellValueElementProps,
'rowIndex' | 'columnId'
>;
export type RowCellRender =
| JSXElementConstructor<GenericActionRowCellRenderProps>
| ((props: GenericActionRowCellRenderProps) => JSX.Element)
| JSXElementConstructor<ActionProps>
| ((props: ActionProps) => JSX.Element);
export interface ActionProps {
action?: RowCellRender;
ariaRowindex: number;
checked: boolean;
columnId: string;
columnValues: string;
data: TimelineNonEcsData[];
disabled?: boolean;
ecsData: Ecs;
eventId: string;
eventIdToNoteIds?: Readonly<Record<string, string[]>>;
index: number;
isEventPinned?: boolean;
isEventViewer?: boolean;
loadingEventIds: Readonly<string[]>;
onEventDetailsPanelOpened: () => void;
onRowSelected: OnRowSelected;
onRuleChange?: () => void;
refetch?: () => void;
rowIndex: number;
setEventsDeleted: SetEventsDeleted;
setEventsLoading: SetEventsLoading;
showCheckboxes: boolean;
showNotes?: boolean;
tabType?: string;
timelineId: string;
toggleShowNotes?: () => void;
width?: number;
}
interface AdditionalControlColumnProps {
ariaRowindex: number;
actionsColumnWidth: number;
columnValues: string;
checked: boolean;
onRowSelected: OnRowSelected;
eventId: string;
id: string;
columnId: string;
loadingEventIds: Readonly<string[]>;
onEventDetailsPanelOpened: () => void;
showCheckboxes: boolean;
// Override these type definitions to support either a generic custom component or the one used in security_solution today.
headerCellRender: HeaderCellRender;
rowCellRender: RowCellRender;
}
export type ControlColumnProps = Omit<
EuiDataGridControlColumn,
keyof AdditionalControlColumnProps
> &
Partial<AdditionalControlColumnProps>;

View file

@ -5,4 +5,18 @@
* 2.0.
*/
import type { Status } from '../detection_engine/schemas/common';
export * from './timeline';
export * from './data_table';
export * from './detail_panel';
export * from './header_actions';
export * from './session_view';
export * from './bulk_actions';
export const FILTER_OPEN: Status = 'open';
export const FILTER_CLOSED: Status = 'closed';
export const FILTER_ACKNOWLEDGED: Status = 'acknowledged';
export type SetEventsLoading = (params: { eventIds: string[]; isLoading: boolean }) => void;
export type SetEventsDeleted = (params: { eventIds: string[]; isDeleted: boolean }) => void;

View file

@ -4,11 +4,10 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export type {
ActionProps,
HeaderActionProps,
GenericActionRowCellRenderProps,
HeaderCellRender,
RowCellRender,
ControlColumnProps,
} from '@kbn/timelines-plugin/common';
export interface SessionViewConfig {
sessionEntityId: string;
jumpToEntityId?: string;
jumpToCursor?: string;
investigatedAlertId?: string;
}

View file

@ -5,4 +5,30 @@
* 2.0.
*/
export type { CellValueElementProps } from '@kbn/timelines-plugin/common';
import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import type { ColumnHeaderOptions, RowRenderer } from '../..';
import type { Ecs } from '../../../ecs';
import type { BrowserFields, TimelineNonEcsData } from '../../../search_strategy';
/** The following props are provided to the function called by `renderCellValue` */
export type CellValueElementProps = EuiDataGridCellValueElementProps & {
asPlainText?: boolean;
browserFields?: BrowserFields;
data: TimelineNonEcsData[];
ecsData?: Ecs;
eventId: string; // _id
globalFilters?: Filter[];
header: ColumnHeaderOptions;
isDraggable: boolean;
isTimeline?: boolean; // Default cell renderer is used for both the alert table and timeline. This allows us to cheaply separate concerns
linkValues: string[] | undefined;
rowRenderers?: RowRenderer[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setFlyoutAlert?: (data: any) => void;
scopeId: string;
truncate?: boolean;
key?: string;
closeCellPopover?: () => void;
enableActions?: boolean;
};

View file

@ -5,9 +5,4 @@
* 2.0.
*/
export type {
ColumnHeaderType,
ColumnId,
ColumnHeaderOptions,
ColumnRenderer,
} from '@kbn/timelines-plugin/common';
export type { ColumnHeaderOptions } from '../../header_actions';

View file

@ -22,12 +22,11 @@ import {
success,
success_count as successCount,
} from '../../detection_engine/schemas/common/schemas';
import type { FlowTargetSourceDest } from '../../search_strategy/security_solution/network';
import { errorSchema } from '../../detection_engine/schemas/response/error_schema';
import type { Maybe } from '../../search_strategy';
import { Direction } from '../../search_strategy';
import type { ExpandedDetailType } from '../detail_panel';
export * from './actions';
export * from './cells';
export * from './columns';
export * from './data_provider';
@ -326,32 +325,6 @@ export enum TimelineId {
detectionsAlertDetailsPage = 'detections-alert-details-page',
}
export enum TableId {
usersPageEvents = 'users-page-events',
hostsPageEvents = 'hosts-page-events',
networkPageEvents = 'network-page-events',
hostsPageSessions = 'hosts-page-sessions-v2', // the v2 is to cache bust localstorage settings as default columns were reworked.
alertsOnRuleDetailsPage = 'alerts-rules-details-page',
alertsOnAlertsPage = 'alerts-page',
test = 'table-test', // Reserved for testing purposes
alternateTest = 'alternateTest',
rulePreview = 'rule-preview',
kubernetesPageSessions = 'kubernetes-page-sessions',
}
export const TableIdLiteralRt = runtimeTypes.union([
runtimeTypes.literal(TableId.usersPageEvents),
runtimeTypes.literal(TableId.hostsPageEvents),
runtimeTypes.literal(TableId.networkPageEvents),
runtimeTypes.literal(TableId.hostsPageSessions),
runtimeTypes.literal(TableId.alertsOnRuleDetailsPage),
runtimeTypes.literal(TableId.alertsOnAlertsPage),
runtimeTypes.literal(TableId.test),
runtimeTypes.literal(TableId.rulePreview),
runtimeTypes.literal(TableId.kubernetesPageSessions),
]);
export type TableIdLiteral = runtimeTypes.TypeOf<typeof TableIdLiteralRt>;
export const TimelineSavedToReturnObjectRuntimeType = runtimeTypes.intersection([
SavedTimelineRuntimeType,
runtimeTypes.type({
@ -484,59 +457,7 @@ export interface ScrollToTopEvent {
timestamp: number;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type EmptyObject = Record<any, never>;
export type TimelineExpandedEventType =
| {
panelView?: 'eventDetail';
params?: {
eventId: string;
indexName: string;
refetch?: () => void;
};
}
| EmptyObject;
export type TimelineExpandedHostType =
| {
panelView?: 'hostDetail';
params?: {
hostName: string;
};
}
| EmptyObject;
export type TimelineExpandedNetworkType =
| {
panelView?: 'networkDetail';
params?: {
ip: string;
flowTarget: FlowTargetSourceDest;
};
}
| EmptyObject;
export type TimelineExpandedUserType =
| {
panelView?: 'userDetail';
params?: {
userName: string;
};
}
| EmptyObject;
export type TimelineExpandedDetailType =
| TimelineExpandedEventType
| TimelineExpandedHostType
| TimelineExpandedNetworkType
| TimelineExpandedUserType;
export type TimelineExpandedDetail = {
[tab in TimelineTabs]?: TimelineExpandedDetailType;
};
export type ToggleDetailPanel = TimelineExpandedDetailType & {
export type ToggleDetailPanel = ExpandedDetailType & {
tabType?: TimelineTabs;
id: string;
};

View file

@ -6,15 +6,11 @@
*/
import type { Filter } from '@kbn/es-query';
import type {
ColumnHeaderOptions,
ColumnId,
RowRendererId,
TimelineExpandedDetail,
TimelineTypeLiteral,
} from '.';
import type { RowRendererId, TimelineTypeLiteral } from '.';
import type { Direction } from '../../search_strategy';
import type { ExpandedDetailTimeline } from '../detail_panel';
import type { ColumnHeaderOptions, ColumnId } from '../header_actions';
import type { DataProvider } from './data_provider';
export type KueryFilterQueryKind = 'kuery' | 'lucene' | 'eql';
@ -47,7 +43,7 @@ export interface TimelinePersistInput {
};
defaultColumns?: ColumnHeaderOptions[];
excludedRowRendererIds?: RowRendererId[];
expandedDetail?: TimelineExpandedDetail;
expandedDetail?: ExpandedDetailTimeline;
filters?: Filter[];
id: string;
indexNames: string[];

View file

@ -26,7 +26,6 @@ import { TimelineId } from '../../../../common/types/timeline';
import { createStore } from '../../../common/store';
import { kibanaObservable } from '@kbn/timelines-plugin/public/mock';
import { sourcererPaths } from '../../../common/containers/sourcerer';
import { tGridReducer } from '@kbn/timelines-plugin/public';
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
@ -73,13 +72,7 @@ describe('global header', () => {
},
};
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
beforeEach(() => {
useVariationMock.mockReset();
@ -176,13 +169,7 @@ describe('global header', () => {
},
},
};
const mockStore = createStore(
mockstate,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const mockStore = createStore(mockstate, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
(useLocation as jest.Mock).mockReturnValue({ pathname: sourcererPaths[2] });

View file

@ -33,7 +33,6 @@ import type { TimelineUrl } from '../../timelines/store/timeline/model';
import { timelineDefaults } from '../../timelines/store/timeline/defaults';
import { URL_PARAM_KEY } from '../../common/hooks/use_url_state';
import { InputsModelId } from '../../common/store/inputs/constants';
import { tGridReducer } from '@kbn/timelines-plugin/public';
jest.mock('../../common/store/inputs/actions');
@ -299,13 +298,7 @@ describe('HomePage', () => {
},
};
const mockStore = createStore(
mockstate,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const mockStore = createStore(mockstate, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
render(
<TestProviders store={mockStore}>
@ -459,13 +452,7 @@ describe('HomePage', () => {
};
const { storage } = createSecuritySolutionStorageMock();
const mockStore = createStore(
mockstate,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const mockStore = createStore(mockstate, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const TestComponent = () => (
<TestProviders store={mockStore}>
@ -522,13 +509,7 @@ describe('HomePage', () => {
};
const { storage } = createSecuritySolutionStorageMock();
const mockStore = createStore(
mockstate,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const mockStore = createStore(mockstate, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const TestComponent = () => (
<TestProviders store={mockStore}>
@ -588,13 +569,7 @@ describe('HomePage', () => {
it('it removes empty timeline state from URL', async () => {
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
mockUseInitializeUrlParam(URL_PARAM_KEY.timeline, {
id: 'testSavedTimelineId',
@ -621,13 +596,7 @@ describe('HomePage', () => {
it('it updates URL when timeline store changes', async () => {
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const savedObjectId = 'testTimelineId';
mockUseInitializeUrlParam(URL_PARAM_KEY.timeline, {

View file

@ -18,8 +18,6 @@ import type {
import type { RouteProps } from 'react-router-dom';
import type { AppMountParameters } from '@kbn/core/public';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import type { TableState } from '@kbn/timelines-plugin/public';
import type { StartServices } from '../types';
/**
@ -35,6 +33,7 @@ export interface RenderAppProps extends AppMountParameters {
import type { State, SubPluginsInitReducer } from '../common/store';
import type { Immutable } from '../../common/endpoint/types';
import type { AppAction } from '../common/store/actions';
import type { TableState } from '../common/store/data_table/types';
export { SecurityPageName } from '../../common/constants';

View file

@ -19,7 +19,6 @@ import type { State } from '../../../../store';
import { createStore } from '../../../../store';
import * as i18n from './translations';
import { useChartSettingsPopoverConfiguration } from '.';
import { tGridReducer } from '@kbn/timelines-plugin/public';
const mockHandleClick = jest.fn();
@ -33,13 +32,7 @@ describe('useChartSettingsPopoverConfiguration', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<TestProviders store={store}>{children}</TestProviders>
);

View file

@ -6,9 +6,10 @@
*/
import { render, fireEvent } from '@testing-library/react';
import { ActionProps, HeaderActionProps } from '../../../../../common/types';
import { HeaderCheckBox, RowCheckBox } from './checkbox';
import React from 'react';
import type { ActionProps, HeaderActionProps } from '../../../../common/types';
import { TimelineTabs } from '../../../../common/types';
describe('checkbox control column', () => {
describe('RowCheckBox', () => {
@ -61,7 +62,7 @@ describe('checkbox control column', () => {
showEventsSelect: true,
showSelectAllCheckbox: true,
sort: [],
tabType: 'query',
tabType: TimelineTabs.query,
timelineId: 'test-timelineId',
};

View file

@ -7,7 +7,7 @@
import { EuiCheckbox, EuiLoadingSpinner } from '@elastic/eui';
import React, { useCallback } from 'react';
import type { ActionProps, HeaderActionProps } from '../../../../../common/types';
import type { ActionProps, HeaderActionProps } from '../../../../common/types';
import * as i18n from './translations';
export const RowCheckBox = ({

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import type { ControlColumnProps } from '../../../../../common/types';
import type { ControlColumnProps } from '../../../../common/types';
import { HeaderCheckBox, RowCheckBox } from './checkbox';
export const checkBoxControlColumn: ControlColumnProps = {
@ -14,3 +14,5 @@ export const checkBoxControlColumn: ControlColumnProps = {
headerCellRender: HeaderCheckBox,
rowCellRender: RowCheckBox,
};
export * from './transform_control_columns';

View file

@ -0,0 +1,74 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TableId } from '../../../../../common/types';
import { render } from '@testing-library/react';
import React from 'react';
import { RowAction } from '.';
import { defaultHeaders, TestProviders } from '../../../mock';
import { getDefaultControlColumn } from '../../../../timelines/components/timeline/body/control_columns';
import { useIsExperimentalFeatureEnabled } from '../../../hooks/use_experimental_features';
jest.mock('../../../hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(true),
}));
const useIsExperimentalFeatureEnabledMock = useIsExperimentalFeatureEnabled as jest.Mock;
useIsExperimentalFeatureEnabledMock.mockReturnValue(true);
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
describe('RowAction', () => {
const sampleData = {
_id: '1',
data: [],
ecs: {
_id: '1',
},
};
const defaultProps = {
columnHeaders: defaultHeaders,
controlColumn: getDefaultControlColumn(5)[0],
data: [sampleData],
disabled: false,
index: 1,
isEventViewer: false,
loadingEventIds: [],
onRowSelected: jest.fn(),
onRuleChange: jest.fn(),
selectedEventIds: {},
tableId: TableId.test,
width: 100,
setEventsLoading: jest.fn(),
setEventsDeleted: jest.fn(),
pageRowIndex: 0,
columnId: 'test-columnId',
isDetails: false,
isExpanded: false,
isExpandable: false,
rowIndex: 0,
colIndex: 0,
setCellProps: jest.fn(),
tabType: 'query',
showCheckboxes: false,
};
test('displays expand events button', () => {
const wrapper = render(
<TestProviders>
<RowAction {...defaultProps} />
</TestProviders>
);
expect(wrapper.getAllByTestId('expand-event')).not.toBeNull();
});
});

View file

@ -5,21 +5,20 @@
* 2.0.
*/
import { EuiDataGridCellValueElementProps } from '@elastic/eui';
import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
import React, { useCallback, useMemo } from 'react';
import { useDispatch } from 'react-redux';
import { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy';
import type {
ColumnHeaderOptions,
ControlColumnProps,
OnRowSelected,
SetEventsLoading,
SetEventsDeleted,
DataExpandedDetailType,
} from '../../../../../common/types/timeline';
import { getMappedNonEcsValue } from '../data_driven_columns';
import { tGridActions } from '../../../../store/t_grid';
SetEventsLoading,
ControlColumnProps,
ExpandedDetailType,
} from '../../../../../common/types';
import { getMappedNonEcsValue } from '../../../../timelines/components/timeline/body/data_driven_columns';
import type { TimelineItem, TimelineNonEcsData } from '../../../../../common/search_strategy';
import type { ColumnHeaderOptions, OnRowSelected } from '../../../../../common/types/timeline';
import { dataTableActions } from '../../../store/data_table';
type Props = EuiDataGridCellValueElementProps & {
columnHeaders: ColumnHeaderOptions[];
@ -89,7 +88,7 @@ const RowActionComponent = ({
);
const handleOnEventDetailPanelOpened = useCallback(() => {
const updatedExpandedDetail: DataExpandedDetailType = {
const updatedExpandedDetail: ExpandedDetailType = {
panelView: 'eventDetail',
params: {
eventId: eventId ?? '',
@ -98,7 +97,7 @@ const RowActionComponent = ({
};
dispatch(
tGridActions.toggleDetailPanel({
dataTableActions.toggleDetailPanel({
...updatedExpandedDetail,
tabType,
id: tableId,

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { TransformColumnsProps } from './transform_control_columns';
import { transformControlColumns } from './transform_control_columns';
describe('transformControlColumns', () => {
const defaultProps: TransformColumnsProps = {
onRowSelected: jest.fn(),
loadingEventIds: [],
showCheckboxes: true,
data: [],
timelineId: 'test-timelineId',
setEventsLoading: jest.fn(),
setEventsDeleted: jest.fn(),
columnHeaders: [],
controlColumns: [],
disabledCellActions: [],
selectedEventIds: {},
tabType: '',
isSelectAllChecked: false,
browserFields: {},
onSelectPage: jest.fn(),
pageSize: 0,
sort: [],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
theme: {} as any,
};
test('displays loader when id is included on loadingEventIds', () => {
const res = transformControlColumns(defaultProps);
expect(res.find).not.toBeNull();
});
});

View file

@ -0,0 +1,152 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { FieldBrowserOptions } from '@kbn/triggers-actions-ui-plugin/public';
import type { EuiDataGridCellValueElementProps, EuiDataGridControlColumn } from '@elastic/eui';
import type { ComponentType } from 'react';
import React from 'react';
import type { EuiTheme } from '@kbn/kibana-react-plugin/common';
import type {
BrowserFields,
TimelineItem,
TimelineNonEcsData,
} from '../../../../common/search_strategy';
import type {
SetEventsDeleted,
SetEventsLoading,
ColumnHeaderOptions,
ControlColumnProps,
OnRowSelected,
OnSelectAll,
SortColumnTable,
} from '../../../../common/types';
import { addBuildingBlockStyle } from '../data_table/helpers';
import { getPageRowIndex } from '../data_table/pagination';
import { RowAction } from './row_action';
const EmptyHeaderCellRender: ComponentType = () => null;
export interface TransformColumnsProps {
columnHeaders: ColumnHeaderOptions[];
controlColumns: ControlColumnProps[];
data: TimelineItem[];
disabledCellActions: string[];
fieldBrowserOptions?: FieldBrowserOptions;
loadingEventIds: string[];
onRowSelected: OnRowSelected;
onRuleChange?: () => void;
selectedEventIds: Record<string, TimelineNonEcsData[]>;
showCheckboxes: boolean;
tabType: string;
timelineId: string;
isSelectAllChecked: boolean;
browserFields: BrowserFields;
onSelectPage: OnSelectAll;
pageSize: number;
sort: SortColumnTable[];
theme: EuiTheme;
setEventsLoading: SetEventsLoading;
setEventsDeleted: SetEventsDeleted;
}
export const transformControlColumns = ({
columnHeaders,
controlColumns,
data,
fieldBrowserOptions,
loadingEventIds,
onRowSelected,
onRuleChange,
selectedEventIds,
showCheckboxes,
tabType,
timelineId,
isSelectAllChecked,
onSelectPage,
browserFields,
pageSize,
sort,
theme,
setEventsLoading,
setEventsDeleted,
}: TransformColumnsProps): EuiDataGridControlColumn[] =>
controlColumns.map(
({ id: columnId, headerCellRender = EmptyHeaderCellRender, rowCellRender, width }, i) => ({
id: `${columnId}`,
headerCellRender: () => {
const HeaderActions = headerCellRender;
return (
<>
{HeaderActions && (
<HeaderActions
width={width}
browserFields={browserFields}
fieldBrowserOptions={fieldBrowserOptions}
columnHeaders={columnHeaders}
isEventViewer={false}
isSelectAllChecked={isSelectAllChecked}
onSelectAll={onSelectPage}
showEventsSelect={false}
showSelectAllCheckbox={showCheckboxes}
sort={sort}
tabType={tabType}
timelineId={timelineId}
/>
)}
</>
);
},
rowCellRender: ({
isDetails,
isExpandable,
isExpanded,
rowIndex,
colIndex,
setCellProps,
}: EuiDataGridCellValueElementProps) => {
const pageRowIndex = getPageRowIndex(rowIndex, pageSize);
const rowData = data[pageRowIndex];
if (rowData) {
addBuildingBlockStyle(rowData.ecs, theme, setCellProps);
} else {
// disable the cell when it has no data
setCellProps({ style: { display: 'none' } });
}
return (
<RowAction
columnId={columnId ?? ''}
columnHeaders={columnHeaders}
controlColumn={controlColumns[i]}
data={data}
disabled={false}
index={i}
isDetails={isDetails}
isExpanded={isExpanded}
isEventViewer={false}
isExpandable={isExpandable}
loadingEventIds={loadingEventIds}
onRowSelected={onRowSelected}
onRuleChange={onRuleChange}
rowIndex={rowIndex}
colIndex={colIndex}
pageRowIndex={pageRowIndex}
selectedEventIds={selectedEventIds}
setCellProps={setCellProps}
showCheckboxes={showCheckboxes}
tabType={tabType}
tableId={timelineId}
width={width}
setEventsLoading={setEventsLoading}
setEventsDeleted={setEventsDeleted}
/>
);
},
width,
})
);

View file

@ -15,7 +15,7 @@ export const CHECKBOX_FOR_ROW = ({
columnValues: string;
checked: boolean;
}) =>
i18n.translate('xpack.timelines.timeline.body.actions.checkboxForRowAriaLabel', {
i18n.translate('xpack.securitySolution.controlColumns.checkboxForRowAriaLabel', {
values: { ariaRowindex, checked, columnValues },
defaultMessage:
'{checked, select, false {unchecked} true {checked}} checkbox for the alert or event in row {ariaRowindex}, with columns {columnValues}',

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import type { ColumnHeaderOptions, ColumnHeaderType } from '../../../../../common/types/timeline';
import { DEFAULT_COLUMN_MIN_WIDTH, DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../constants';
import type { ColumnHeaderType } from '../../../../../common/types';
import type { ColumnHeaderOptions } from '../../../../../common/types/timeline';
import { DEFAULT_TABLE_COLUMN_MIN_WIDTH, DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH } from '../constants';
export const defaultColumnHeaderType: ColumnHeaderType = 'not-filtered';
@ -14,43 +15,43 @@ export const defaultHeaders: ColumnHeaderOptions[] = [
{
columnHeaderType: defaultColumnHeaderType,
id: '@timestamp',
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH,
esTypes: ['date'],
type: 'date',
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'message',
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'event.category',
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'event.action',
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'host.name',
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'source.ip',
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'destination.ip',
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH,
},
{
columnHeaderType: defaultColumnHeaderType,
id: 'user.name',
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH,
},
];

View file

@ -4,27 +4,22 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { euiThemeVars } from '@kbn/ui-theme';
import { mount } from 'enzyme';
import { omit, set } from 'lodash/fp';
import React from 'react';
import { defaultHeaders } from './default_headers';
import type { BUILT_IN_SCHEMA } from './helpers';
import {
BUILT_IN_SCHEMA,
getActionsColumnWidth,
getColumnWidthFromType,
getColumnHeaders,
getSchema,
getColumnHeader,
allowSorting,
} from './helpers';
import {
DEFAULT_ACTION_BUTTON_WIDTH,
DEFAULT_COLUMN_MIN_WIDTH,
DEFAULT_DATE_COLUMN_MIN_WIDTH,
} from '../constants';
import { mockBrowserFields } from '../../../../mock/browser_fields';
import { ColumnHeaderOptions } from '../../../../../common/types';
import { DEFAULT_TABLE_COLUMN_MIN_WIDTH, DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH } from '../constants';
import type { ColumnHeaderOptions } from '../../../../../common/types';
import { mockBrowserFields } from '../../../containers/source/mock';
import { defaultHeaders } from '../../../store/data_table/defaults';
window.matchMedia = jest.fn().mockImplementation((query) => {
return {
@ -39,11 +34,11 @@ window.matchMedia = jest.fn().mockImplementation((query) => {
describe('helpers', () => {
describe('getColumnWidthFromType', () => {
test('it returns the expected width for a non-date column', () => {
expect(getColumnWidthFromType('keyword')).toEqual(DEFAULT_COLUMN_MIN_WIDTH);
expect(getColumnWidthFromType('keyword')).toEqual(DEFAULT_TABLE_COLUMN_MIN_WIDTH);
});
test('it returns the expected width for a date column', () => {
expect(getColumnWidthFromType('date')).toEqual(DEFAULT_DATE_COLUMN_MIN_WIDTH);
expect(getColumnWidthFromType('date')).toEqual(DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH);
});
});
@ -80,7 +75,7 @@ describe('helpers', () => {
expect(getColumnHeader(field, [])).toEqual({
columnHeaderType: 'not-filtered',
id: field,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH,
});
});
@ -92,7 +87,7 @@ describe('helpers', () => {
{
columnHeaderType: 'not-filtered',
id: field,
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH,
esTypes: ['date'],
type: 'date',
},
@ -100,7 +95,7 @@ describe('helpers', () => {
).toEqual({
columnHeaderType: 'not-filtered',
id: field,
initialWidth: DEFAULT_DATE_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH,
esTypes: ['date'],
type: 'date',
});
@ -251,6 +246,7 @@ describe('helpers', () => {
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
isSortable,
name: '@timestamp',
readFromDocValues: true,
schema: 'datetime',
searchable: true,
type: 'date',
@ -263,12 +259,14 @@ describe('helpers', () => {
columnHeaderType: 'not-filtered',
defaultSortDirection,
description: 'IP address of the source. Can be one or multiple IPv4 or IPv6 addresses.',
esTypes: ['ip'],
example: '',
format: '',
id: 'source.ip',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
isSortable,
name: 'source.ip',
schema: undefined,
searchable: true,
type: 'ip',
initialWidth: 180,
@ -281,12 +279,14 @@ describe('helpers', () => {
defaultSortDirection,
description:
'IP address of the destination. Can be one or multiple IPv4 or IPv6 addresses.',
esTypes: ['ip'],
example: '',
format: '',
id: 'destination.ip',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
isSortable,
name: 'destination.ip',
schema: undefined,
searchable: true,
type: 'ip',
initialWidth: 180,
@ -309,12 +309,14 @@ describe('helpers', () => {
defaultSortDirection,
description:
'Date/time when the event originated. For log events this is the date/time when the event was generated, and not when it was read. Required field for all events.',
esTypes: ['date'],
example: '2016-05-23T08:05:34.853Z',
format: '',
id: '@timestamp',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
isSortable,
name: '@timestamp',
readFromDocValues: true,
schema: 'custom', // <-- we expect our custom schema will NOT be overridden by a built-in schema
searchable: true,
type: 'date', // <-- the built-in schema for `type: 'date'` is 'datetime', but the custom schema overrides it
@ -459,34 +461,70 @@ describe('helpers', () => {
});
});
describe('getActionsColumnWidth', () => {
// ideally the following implementation detail wouldn't be part of these tests,
// but without it, the test would be brittle when `euiDataGridCellPaddingM` changes:
const expectedPadding = parseInt(euiThemeVars.euiDataGridCellPaddingM, 10) * 2;
describe('allowSorting', () => {
const aggregatableField = {
category: 'cloud',
description:
'The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier.',
example: '666777888999',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'cloud.account.id',
searchable: true,
type: 'string',
aggregatable: true, // <-- allow sorting when this is true
format: '',
};
test('it returns the expected width', () => {
const ACTION_BUTTON_COUNT = 5;
const expectedContentWidth = ACTION_BUTTON_COUNT * DEFAULT_ACTION_BUTTON_WIDTH;
expect(getActionsColumnWidth(ACTION_BUTTON_COUNT)).toEqual(
expectedContentWidth + expectedPadding
);
test('it returns true for an aggregatable field', () => {
expect(
allowSorting({
browserField: aggregatableField,
fieldName: aggregatableField.name,
})
).toBe(true);
});
test('it returns the minimum width when the button count is zero', () => {
const ACTION_BUTTON_COUNT = 0;
expect(getActionsColumnWidth(ACTION_BUTTON_COUNT)).toEqual(
DEFAULT_ACTION_BUTTON_WIDTH + expectedPadding
);
test('it returns true for a allow-listed non-BrowserField', () => {
expect(
allowSorting({
browserField: undefined, // no BrowserField metadata for this field
fieldName: 'kibana.alert.rule.name', // an allow-listed field name
})
).toBe(true);
});
test('it returns the minimum width when the button count is negative', () => {
const ACTION_BUTTON_COUNT = -1;
test('it returns false for a NON-aggregatable field (aggregatable is false)', () => {
const nonaggregatableField = {
...aggregatableField,
aggregatable: false, // <-- NON-aggregatable
};
expect(getActionsColumnWidth(ACTION_BUTTON_COUNT)).toEqual(
DEFAULT_ACTION_BUTTON_WIDTH + expectedPadding
);
expect(
allowSorting({
browserField: nonaggregatableField,
fieldName: nonaggregatableField.name,
})
).toBe(false);
});
test('it returns false if the BrowserField is missing the aggregatable property', () => {
const missingAggregatable = omit('aggregatable', aggregatableField);
expect(
allowSorting({
browserField: missingAggregatable,
fieldName: missingAggregatable.name,
})
).toBe(false);
});
test("it returns false for a non-allowlisted field we don't have `BrowserField` metadata for it", () => {
expect(
allowSorting({
browserField: undefined, // <-- no metadata for this field
fieldName: 'non-allowlisted',
})
).toBe(false);
});
});
});

View file

@ -5,8 +5,7 @@
* 2.0.
*/
import { euiThemeVars } from '@kbn/ui-theme';
import { EuiDataGridColumnActions } from '@elastic/eui';
import type { EuiDataGridColumnActions } from '@elastic/eui';
import { keyBy } from 'lodash/fp';
import React from 'react';
@ -15,13 +14,8 @@ import type {
BrowserFields,
} from '../../../../../common/search_strategy/index_fields';
import type { ColumnHeaderOptions } from '../../../../../common/types/timeline';
import {
DEFAULT_ACTION_BUTTON_WIDTH,
DEFAULT_COLUMN_MIN_WIDTH,
DEFAULT_DATE_COLUMN_MIN_WIDTH,
} from '../constants';
import { allowSorting } from '../helpers';
import { defaultColumnHeaderType } from './default_headers';
import { DEFAULT_TABLE_COLUMN_MIN_WIDTH, DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH } from '../constants';
import { defaultColumnHeaderType } from '../../../store/data_table/defaults';
const defaultActions: EuiDataGridColumnActions = {
showSortAsc: true,
@ -29,6 +23,79 @@ const defaultActions: EuiDataGridColumnActions = {
showHide: false,
};
export const allowSorting = ({
browserField,
fieldName,
}: {
browserField: Partial<BrowserField> | undefined;
fieldName: string;
}): boolean => {
const isAggregatable = browserField?.aggregatable ?? false;
const isAllowlistedNonBrowserField = [
'kibana.alert.ancestors.depth',
'kibana.alert.ancestors.id',
'kibana.alert.ancestors.rule',
'kibana.alert.ancestors.type',
'kibana.alert.original_event.action',
'kibana.alert.original_event.category',
'kibana.alert.original_event.code',
'kibana.alert.original_event.created',
'kibana.alert.original_event.dataset',
'kibana.alert.original_event.duration',
'kibana.alert.original_event.end',
'kibana.alert.original_event.hash',
'kibana.alert.original_event.id',
'kibana.alert.original_event.kind',
'kibana.alert.original_event.module',
'kibana.alert.original_event.original',
'kibana.alert.original_event.outcome',
'kibana.alert.original_event.provider',
'kibana.alert.original_event.risk_score',
'kibana.alert.original_event.risk_score_norm',
'kibana.alert.original_event.sequence',
'kibana.alert.original_event.severity',
'kibana.alert.original_event.start',
'kibana.alert.original_event.timezone',
'kibana.alert.original_event.type',
'kibana.alert.original_time',
'kibana.alert.reason',
'kibana.alert.rule.created_by',
'kibana.alert.rule.description',
'kibana.alert.rule.enabled',
'kibana.alert.rule.false_positives',
'kibana.alert.rule.from',
'kibana.alert.rule.uuid',
'kibana.alert.rule.immutable',
'kibana.alert.rule.interval',
'kibana.alert.rule.max_signals',
'kibana.alert.rule.name',
'kibana.alert.rule.note',
'kibana.alert.rule.references',
'kibana.alert.risk_score',
'kibana.alert.rule.rule_id',
'kibana.alert.severity',
'kibana.alert.rule.size',
'kibana.alert.rule.tags',
'kibana.alert.rule.threat',
'kibana.alert.rule.threat.tactic.id',
'kibana.alert.rule.threat.tactic.name',
'kibana.alert.rule.threat.tactic.reference',
'kibana.alert.rule.threat.technique.id',
'kibana.alert.rule.threat.technique.name',
'kibana.alert.rule.threat.technique.reference',
'kibana.alert.rule.timeline_id',
'kibana.alert.rule.timeline_title',
'kibana.alert.rule.to',
'kibana.alert.rule.type',
'kibana.alert.rule.updated_by',
'kibana.alert.rule.version',
'kibana.alert.workflow_status',
].includes(fieldName);
return isAllowlistedNonBrowserField || isAggregatable;
};
const getAllBrowserFields = (browserFields: BrowserFields): Array<Partial<BrowserField>> =>
Object.values(browserFields).reduce<Array<Partial<BrowserField>>>(
(acc, namespace) => [
@ -127,32 +194,9 @@ export const getColumnHeader = (
): ColumnHeaderOptions => ({
columnHeaderType: defaultColumnHeaderType,
id: fieldName,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
initialWidth: DEFAULT_TABLE_COLUMN_MIN_WIDTH,
...(defaultHeaders.find((c) => c.id === fieldName) ?? {}),
});
export const getColumnWidthFromType = (type: string): number =>
type !== 'date' ? DEFAULT_COLUMN_MIN_WIDTH : DEFAULT_DATE_COLUMN_MIN_WIDTH;
/**
* Returns the width of the Actions column based on the number of buttons being
* displayed
*
* NOTE: This function is necessary because `width` is a required property of
* the `EuiDataGridControlColumn` interface, so it must be calculated before
* content is rendered. (The width of a `EuiDataGridControlColumn` does not
* automatically size itself to fit all the content.)
*/
export const getActionsColumnWidth = (actionButtonCount: number): number => {
const contentWidth =
actionButtonCount > 0
? actionButtonCount * DEFAULT_ACTION_BUTTON_WIDTH
: DEFAULT_ACTION_BUTTON_WIDTH;
// `EuiDataGridRowCell` applies additional `padding-left` and
// `padding-right`, which must be added to the content width to prevent the
// content from being partially hidden due to the space occupied by padding:
const leftRightCellPadding = parseInt(euiThemeVars.euiDataGridCellPaddingM, 10) * 2; // parseInt ignores the trailing `px`, e.g. `6px`
return contentWidth + leftRightCellPadding;
};
type !== 'date' ? DEFAULT_TABLE_COLUMN_MIN_WIDTH : DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH;

View file

@ -7,9 +7,9 @@
import { i18n } from '@kbn/i18n';
export const ERROR_TIMELINE_EVENTS = i18n.translate(
'xpack.timelines.timelineEvents.errorSearchDescription',
export const REMOVE_COLUMN = i18n.translate(
'xpack.securitySolution.columnHeaders.flyout.pane.removeColumnButtonLabel',
{
defaultMessage: `An error has occurred on timeline events search`,
defaultMessage: 'Remove column',
}
);

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
/** The default minimum width of a column (when a width for the column type is not specified) */
export const DEFAULT_TABLE_COLUMN_MIN_WIDTH = 180; // px
/** The default minimum width of a column of type `date` */
export const DEFAULT_TABLE_DATE_COLUMN_MIN_WIDTH = 190; // px

View file

@ -5,11 +5,8 @@
* 2.0.
*/
import { omit } from 'lodash/fp';
import { ColumnHeaderOptions } from '../../../../common/types';
import type { ColumnHeaderOptions } from '../../../../common/types';
import {
allowSorting,
hasCellActions,
mapSortDirectionToDirection,
mapSortingColumns,
@ -17,7 +14,7 @@ import {
} from './helpers';
import { euiThemeVars } from '@kbn/ui-theme';
import { mockDnsEvent } from '../../../mock';
import { mockDnsEvent } from '../../mock';
describe('helpers', () => {
describe('mapSortDirectionToDirection', () => {
@ -180,73 +177,6 @@ describe('helpers', () => {
});
});
describe('allowSorting', () => {
const aggregatableField = {
category: 'cloud',
description:
'The cloud account or organization id used to identify different entities in a multi-tenant environment. Examples: AWS account id, Google Cloud ORG Id, or other unique identifier.',
example: '666777888999',
indexes: ['auditbeat', 'filebeat', 'packetbeat'],
name: 'cloud.account.id',
searchable: true,
type: 'string',
aggregatable: true, // <-- allow sorting when this is true
format: '',
};
test('it returns true for an aggregatable field', () => {
expect(
allowSorting({
browserField: aggregatableField,
fieldName: aggregatableField.name,
})
).toBe(true);
});
test('it returns true for a allow-listed non-BrowserField', () => {
expect(
allowSorting({
browserField: undefined, // no BrowserField metadata for this field
fieldName: 'kibana.alert.rule.name', // an allow-listed field name
})
).toBe(true);
});
test('it returns false for a NON-aggregatable field (aggregatable is false)', () => {
const nonaggregatableField = {
...aggregatableField,
aggregatable: false, // <-- NON-aggregatable
};
expect(
allowSorting({
browserField: nonaggregatableField,
fieldName: nonaggregatableField.name,
})
).toBe(false);
});
test('it returns false if the BrowserField is missing the aggregatable property', () => {
const missingAggregatable = omit('aggregatable', aggregatableField);
expect(
allowSorting({
browserField: missingAggregatable,
fieldName: missingAggregatable.name,
})
).toBe(false);
});
test("it returns false for a non-allowlisted field we don't have `BrowserField` metadata for it", () => {
expect(
allowSorting({
browserField: undefined, // <-- no metadata for this field
fieldName: 'non-allowlisted',
})
).toBe(false);
});
});
describe('addBuildingBlockStyle', () => {
const THEME = { eui: euiThemeVars, darkMode: false };

View file

@ -0,0 +1,114 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEmpty } from 'lodash/fp';
import type { EuiDataGridCellValueElementProps } from '@elastic/eui';
import type { EuiTheme } from '@kbn/kibana-react-plugin/common';
import type { SortColumnTable } from '../../../../common/types';
import type { Ecs } from '../../../../common/ecs';
import type { TimelineItem, TimelineNonEcsData } from '../../../../common/search_strategy';
import type { ColumnHeaderOptions, SortDirection } from '../../../../common/types/timeline';
/**
* Creates mapping of eventID -> fieldData for given fieldsToKeep. Used to store additional field
* data necessary for custom timeline actions in conjunction with selection state
* @param data
* @param eventIds
* @param fieldsToKeep
*/
export const getEventIdToDataMapping = (
timelineData: TimelineItem[],
eventIds: string[],
fieldsToKeep: string[],
hasAlertsCrud: boolean
): Record<string, TimelineNonEcsData[]> =>
timelineData.reduce((acc, v) => {
const fvm =
hasAlertsCrud && eventIds.includes(v._id)
? { [v._id]: v.data.filter((ti) => fieldsToKeep.includes(ti.field)) }
: {};
return {
...acc,
...fvm,
};
}, {});
export const isEventBuildingBlockType = (event: Ecs): boolean =>
!isEmpty(event.kibana?.alert?.building_block_type);
/** Maps (Redux) `SortDirection` to the `direction` values used by `EuiDataGrid` */
export const mapSortDirectionToDirection = (sortDirection: SortDirection): 'asc' | 'desc' => {
switch (sortDirection) {
case 'asc': // fall through
case 'desc':
return sortDirection;
default:
return 'desc';
}
};
/**
* Maps `EuiDataGrid` columns to their Redux representation by combining the
* `columns` with metadata from `columnHeaders`
*/
export const mapSortingColumns = ({
columns,
columnHeaders,
}: {
columnHeaders: ColumnHeaderOptions[];
columns: Array<{
id: string;
direction: 'asc' | 'desc';
}>;
}): SortColumnTable[] =>
columns.map(({ id, direction }) => {
const columnHeader = columnHeaders.find((ch) => ch.id === id);
const columnType = columnHeader?.type ?? '';
const esTypes = columnHeader?.esTypes ?? [];
return {
columnId: id,
columnType,
esTypes,
sortDirection: direction,
};
});
export const addBuildingBlockStyle = (
ecs: Ecs,
theme: EuiTheme,
setCellProps: EuiDataGridCellValueElementProps['setCellProps'],
defaultStyles?: React.CSSProperties
) => {
const currentStyles = defaultStyles ?? {};
if (isEventBuildingBlockType(ecs)) {
setCellProps({
style: {
...currentStyles,
backgroundColor: `${theme.eui.euiColorHighlight}`,
},
});
} else {
// reset cell style
setCellProps({
style: {
...currentStyles,
backgroundColor: 'inherit',
},
});
}
};
/** Returns true when the specified column has cell actions */
export const hasCellActions = ({
columnId,
disabledCellActions,
}: {
columnId: string;
disabledCellActions: string[];
}) => !disabledCellActions.includes(columnId);

View file

@ -8,25 +8,17 @@
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { BodyComponent, StatefulBodyProps } from '.';
import { Sort } from './sort';
import type { DataTableProps } from '.';
import { DataTableComponent } from '.';
import { REMOVE_COLUMN } from './column_headers/translations';
import { Direction } from '../../../../common/search_strategy';
import { useMountAppended } from '../../utils/use_mount_appended';
import { defaultHeaders, mockBrowserFields, mockTimelineData, TestProviders } from '../../../mock';
import { TestCellRenderer } from '../../../mock/cell_renderer';
import { mockGlobalState } from '../../../mock/global_state';
import { EuiDataGridColumn } from '@elastic/eui';
import { defaultColumnHeaderType } from '../../../store/t_grid/defaults';
const mockSort: Sort[] = [
{
columnId: '@timestamp',
columnType: 'date',
esTypes: ['date'],
sortDirection: Direction.desc,
},
];
import type { EuiDataGridColumn } from '@elastic/eui';
import { defaultHeaders, mockGlobalState, mockTimelineData, TestProviders } from '../../mock';
import { defaultColumnHeaderType } from '../../store/data_table/defaults';
import { mockBrowserFields } from '../../containers/source/mock';
import { getMappedNonEcsValue } from '../../../timelines/components/timeline/body/data_driven_columns';
import type { CellValueElementProps } from '../../../../common/types';
import { TableId } from '../../../../common/types';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
@ -52,9 +44,9 @@ jest.mock('@kbn/kibana-react-plugin/public', () => {
};
});
jest.mock('../../../hooks/use_selector', () => ({
useShallowEqualSelector: () => mockGlobalState.tableById['table-test'],
useDeepEqualSelector: () => mockGlobalState.tableById['table-test'],
jest.mock('../../hooks/use_selector', () => ({
useShallowEqualSelector: () => mockGlobalState.dataTable.tableById['table-test'],
useDeepEqualSelector: () => mockGlobalState.dataTable.tableById['table-test'],
}));
jest.mock(
@ -74,38 +66,35 @@ window.matchMedia = jest.fn().mockImplementation((query) => {
};
});
describe('Body', () => {
export const TestCellRenderer: React.FC<CellValueElementProps> = ({ columnId, data }) => (
<>
{getMappedNonEcsValue({
data,
fieldName: columnId,
})?.reduce((x) => x[0]) ?? ''}
</>
);
describe('DataTable', () => {
const mount = useMountAppended();
const props: StatefulBodyProps = {
activePage: 0,
const props: DataTableProps = {
browserFields: mockBrowserFields,
clearSelected: jest.fn() as unknown as StatefulBodyProps['clearSelected'],
columnHeaders: defaultHeaders,
data: mockTimelineData,
defaultCellActions: [],
disabledCellActions: ['signal.rule.risk_score', 'signal.reason'],
id: 'timeline-test',
isSelectAllChecked: false,
isLoading: false,
itemsPerPageOptions: [],
loadingEventIds: [],
id: TableId.test,
loadPage: jest.fn(),
pageSize: 25,
renderCellValue: TestCellRenderer,
rowRenderers: [],
selectedEventIds: {},
setSelected: jest.fn() as unknown as StatefulBodyProps['setSelected'],
sort: mockSort,
showCheckboxes: false,
tabType: 'query',
tableView: 'gridView',
totalItems: 1,
leadingControlColumns: [],
trailingControlColumns: [],
filterStatus: 'open',
filterQuery: '',
refetch: jest.fn(),
indexNames: [''],
unitCountText: '10 events',
pagination: {
pageSize: 25,
pageIndex: 0,
onChangeItemsPerPage: jest.fn(),
onChangePage: jest.fn(),
},
};
beforeEach(() => {
@ -116,7 +105,7 @@ describe('Body', () => {
test('it renders the body data grid', () => {
const wrapper = mount(
<TestProviders>
<BodyComponent {...props} />
<DataTableComponent {...props} />
</TestProviders>
);
expect(wrapper.find('[data-test-subj="body-data-grid"]').first().exists()).toEqual(true);
@ -125,7 +114,7 @@ describe('Body', () => {
test('it renders the column headers', () => {
const wrapper = mount(
<TestProviders>
<BodyComponent {...props} />
<DataTableComponent {...props} />
</TestProviders>
);
@ -135,7 +124,7 @@ describe('Body', () => {
test('it renders the scroll container', () => {
const wrapper = mount(
<TestProviders>
<BodyComponent {...props} />
<DataTableComponent {...props} />
</TestProviders>
);
@ -145,7 +134,7 @@ describe('Body', () => {
test('it renders events', () => {
const wrapper = mount(
<TestProviders>
<BodyComponent {...props} />
<DataTableComponent {...props} />
</TestProviders>
);
@ -161,7 +150,7 @@ describe('Body', () => {
};
const wrapper = mount(
<TestProviders>
<BodyComponent {...testProps} />
<DataTableComponent {...testProps} />
</TestProviders>
);
wrapper.update();
@ -184,7 +173,7 @@ describe('Body', () => {
};
const wrapper = mount(
<TestProviders>
<BodyComponent {...testProps} />
<DataTableComponent {...testProps} />
</TestProviders>
);
wrapper.update();
@ -216,7 +205,7 @@ describe('Body', () => {
};
const wrapper = mount(
<TestProviders>
<BodyComponent {...testProps} />
<DataTableComponent {...testProps} />
</TestProviders>
);
wrapper.update();
@ -248,7 +237,7 @@ describe('Body', () => {
};
const wrapper = mount(
<TestProviders>
<BodyComponent {...testProps} />
<DataTableComponent {...testProps} />
</TestProviders>
);
wrapper.update();
@ -281,7 +270,7 @@ describe('Body', () => {
};
const wrapper = mount(
<TestProviders>
<BodyComponent {...testProps} />
<DataTableComponent {...testProps} />
</TestProviders>
);
wrapper.update();
@ -298,7 +287,7 @@ describe('Body', () => {
test('it does NOT render switches for hiding columns in the `EuiDataGrid` `Columns` popover', async () => {
render(
<TestProviders>
<BodyComponent {...props} />
<DataTableComponent {...props} />
</TestProviders>
);
@ -314,7 +303,7 @@ describe('Body', () => {
test('it dispatches the `REMOVE_COLUMN` action when a user clicks `Remove column` in the column header popover', async () => {
render(
<TestProviders>
<BodyComponent {...props} />
<DataTableComponent {...props} />
</TestProviders>
);
@ -325,15 +314,15 @@ describe('Body', () => {
fireEvent.click(await screen.getByText(REMOVE_COLUMN));
expect(mockDispatch).toBeCalledWith({
payload: { columnId: '@timestamp', id: 'timeline-test' },
type: 'x-pack/timelines/t-grid/REMOVE_COLUMN',
payload: { columnId: '@timestamp', id: 'table-test' },
type: 'x-pack/security_solution/data-table/REMOVE_COLUMN',
});
});
test('it dispatches the `UPDATE_COLUMN_WIDTH` action when a user resizes a column', async () => {
render(
<TestProviders>
<BodyComponent {...props} />
<DataTableComponent {...props} />
</TestProviders>
);
@ -343,8 +332,8 @@ describe('Body', () => {
fireEvent.mouseUp(screen.getAllByTestId('dataGridColumnResizer')[0]);
expect(mockDispatch).toBeCalledWith({
payload: { columnId: '@timestamp', id: 'timeline-test', width: NaN },
type: 'x-pack/timelines/t-grid/UPDATE_COLUMN_WIDTH',
payload: { columnId: '@timestamp', id: 'table-test', width: NaN },
type: 'x-pack/security_solution/data-table/UPDATE_COLUMN_WIDTH',
});
});
});

View file

@ -0,0 +1,425 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type {
EuiDataGridRefProps,
EuiDataGridColumn,
EuiDataGridCellValueElementProps,
EuiDataGridStyle,
EuiDataGridToolBarVisibilityOptions,
EuiDataGridControlColumn,
EuiDataGridPaginationProps,
} from '@elastic/eui';
import { EuiDataGrid, EuiProgress } from '@elastic/eui';
import { getOr } from 'lodash/fp';
import memoizeOne from 'memoize-one';
import React, { useCallback, useEffect, useMemo, useContext, useRef } from 'react';
import { useDispatch } from 'react-redux';
import styled, { ThemeContext } from 'styled-components';
import type { Filter } from '@kbn/es-query';
import type { EuiTheme } from '@kbn/kibana-react-plugin/common';
import type { FieldBrowserOptions } from '@kbn/triggers-actions-ui-plugin/public';
import { i18n } from '@kbn/i18n';
import type { DataTableCellAction } from '../../../../common/types';
import type {
CellValueElementProps,
ColumnHeaderOptions,
RowRenderer,
} from '../../../../common/types/timeline';
import type { TimelineItem } from '../../../../common/search_strategy/timeline';
import { getColumnHeader, getColumnHeaders } from './column_headers/helpers';
import {
addBuildingBlockStyle,
hasCellActions,
mapSortDirectionToDirection,
mapSortingColumns,
} from './helpers';
import type { BrowserFields } from '../../../../common/search_strategy/index_fields';
import { REMOVE_COLUMN } from './column_headers/translations';
import { dataTableActions, dataTableSelectors } from '../../store/data_table';
import type { BulkActionsProp } from '../toolbar/bulk_actions/types';
import { useKibana } from '../../lib/kibana';
import { getPageRowIndex } from './pagination';
import { UnitCount } from '../toolbar/unit';
import { useShallowEqualSelector } from '../../hooks/use_selector';
import { tableDefaults } from '../../store/data_table/defaults';
const DATA_TABLE_ARIA_LABEL = i18n.translate('xpack.securitySolution.dataTable.ariaLabel', {
defaultMessage: 'Alerts',
});
export interface DataTableProps {
additionalControls?: React.ReactNode;
browserFields: BrowserFields;
bulkActions?: BulkActionsProp;
data: TimelineItem[];
defaultCellActions?: DataTableCellAction[];
disabledCellActions: string[];
fieldBrowserOptions?: FieldBrowserOptions;
filters?: Filter[];
id: string;
leadingControlColumns: EuiDataGridControlColumn[];
loadPage: (newActivePage: number) => void;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
hasCrudPermissions?: boolean;
unitCountText: string;
pagination: EuiDataGridPaginationProps;
totalItems: number;
}
const ES_LIMIT_COUNT = 9999;
const gridStyle: EuiDataGridStyle = { border: 'none', fontSize: 's', header: 'underline' };
const EuiDataGridContainer = styled.div<{ hideLastPage: boolean }>`
ul.euiPagination__list {
li.euiPagination__item:last-child {
${({ hideLastPage }) => `${hideLastPage ? 'display:none' : ''}`};
}
}
`;
export const DataTableComponent = React.memo<DataTableProps>(
({
additionalControls,
browserFields,
bulkActions = true,
data,
defaultCellActions,
disabledCellActions,
fieldBrowserOptions,
filters,
hasCrudPermissions,
id,
leadingControlColumns,
loadPage,
renderCellValue,
rowRenderers,
pagination,
unitCountText,
totalItems,
}) => {
const {
triggersActionsUi: { getFieldBrowser },
} = useKibana().services;
const memoizedColumnHeaders: (
headers: ColumnHeaderOptions[],
browserFields: BrowserFields
) => ColumnHeaderOptions[] = memoizeOne(getColumnHeaders);
const getDataTable = dataTableSelectors.getTableByIdSelector();
const dataTable = useShallowEqualSelector((state) => getDataTable(state, id) ?? tableDefaults);
const { columns, selectedEventIds, showCheckboxes, sort, isLoading, defaultColumns } =
dataTable;
const columnHeaders = memoizedColumnHeaders(columns, browserFields);
const dataGridRef = useRef<EuiDataGridRefProps>(null);
const dispatch = useDispatch();
const selectedCount = useMemo(() => Object.keys(selectedEventIds).length, [selectedEventIds]);
const theme: EuiTheme = useContext(ThemeContext);
const showBulkActions = useMemo(() => {
if (!hasCrudPermissions) {
return false;
}
if (selectedCount === 0 || !showCheckboxes) {
return false;
}
if (typeof bulkActions === 'boolean') {
return bulkActions;
}
return (bulkActions?.customBulkActions?.length || bulkActions?.alertStatusActions) ?? true;
}, [hasCrudPermissions, selectedCount, showCheckboxes, bulkActions]);
const onResetColumns = useCallback(() => {
dispatch(dataTableActions.updateColumns({ id, columns: defaultColumns }));
}, [defaultColumns, dispatch, id]);
const onToggleColumn = useCallback(
(columnId: string) => {
if (columnHeaders.some(({ id: columnHeaderId }) => columnId === columnHeaderId)) {
dispatch(
dataTableActions.removeColumn({
columnId,
id,
})
);
} else {
dispatch(
dataTableActions.upsertColumn({
column: getColumnHeader(columnId, defaultColumns),
id,
index: 1,
})
);
}
},
[columnHeaders, dispatch, id, defaultColumns]
);
const toolbarVisibility: EuiDataGridToolBarVisibilityOptions = useMemo(
() => ({
additionalControls: {
left: {
append: (
<>
{isLoading && <EuiProgress size="xs" position="absolute" color="accent" />}
<UnitCount data-test-subj="server-side-event-count">{unitCountText}</UnitCount>
{additionalControls ?? null}
{getFieldBrowser({
browserFields,
options: fieldBrowserOptions,
columnIds: columnHeaders.map(({ id: columnId }) => columnId),
onResetColumns,
onToggleColumn,
})}
</>
),
},
},
...(showBulkActions
? {
showColumnSelector: false,
showSortSelector: false,
showFullScreenSelector: false,
}
: {
showColumnSelector: { allowHide: false, allowReorder: true },
showSortSelector: true,
showFullScreenSelector: true,
}),
showDisplaySelector: false,
}),
[
isLoading,
unitCountText,
additionalControls,
getFieldBrowser,
browserFields,
fieldBrowserOptions,
columnHeaders,
onResetColumns,
onToggleColumn,
showBulkActions,
]
);
const sortingColumns: Array<{
id: string;
direction: 'asc' | 'desc';
}> = useMemo(
() =>
sort.map((x) => ({
id: x.columnId,
direction: mapSortDirectionToDirection(x.sortDirection),
})),
[sort]
);
const onSort = useCallback(
(
nextSortingColumns: Array<{
id: string;
direction: 'asc' | 'desc';
}>
) => {
dispatch(
dataTableActions.updateSort({
id,
sort: mapSortingColumns({ columns: nextSortingColumns, columnHeaders }),
})
);
setTimeout(() => {
// schedule the query to be re-executed from page 0, (but only after the
// store has been updated with the new sort):
if (loadPage != null) {
loadPage(0);
}
}, 0);
},
[columnHeaders, dispatch, id, loadPage]
);
const visibleColumns = useMemo(() => columnHeaders.map(({ id: cid }) => cid), [columnHeaders]); // the full set of columns
const onColumnResize = useCallback(
({ columnId, width }: { columnId: string; width: number }) => {
dispatch(
dataTableActions.updateColumnWidth({
columnId,
id,
width,
})
);
},
[dispatch, id]
);
const onSetVisibleColumns = useCallback(
(newVisibleColumns: string[]) => {
dispatch(
dataTableActions.updateColumnOrder({
columnIds: newVisibleColumns,
id,
})
);
},
[dispatch, id]
);
const columnsWithCellActions: EuiDataGridColumn[] = useMemo(
() =>
columnHeaders.map((header) => {
const buildAction = (dataTableCellAction: DataTableCellAction) =>
dataTableCellAction({
browserFields,
data: data.map((row) => row.data),
ecsData: data.map((row) => row.ecs),
header: columnHeaders.find((h) => h.id === header.id),
pageSize: pagination.pageSize,
scopeId: id,
closeCellPopover: dataGridRef.current?.closeCellPopover,
});
return {
...header,
actions: {
...header.actions,
additional: [
{
iconType: 'cross',
label: REMOVE_COLUMN,
onClick: () => {
dispatch(dataTableActions.removeColumn({ id, columnId: header.id }));
},
size: 'xs',
},
],
},
...(hasCellActions({
columnId: header.id,
disabledCellActions,
})
? {
cellActions:
header.dataTableCellActions?.map(buildAction) ??
defaultCellActions?.map(buildAction),
visibleCellActions: 3,
}
: {}),
};
}),
[
browserFields,
columnHeaders,
data,
defaultCellActions,
disabledCellActions,
dispatch,
id,
pagination.pageSize,
]
);
const renderTGridCellValue = useMemo(() => {
const Cell: React.FC<EuiDataGridCellValueElementProps> = ({
columnId,
rowIndex,
colIndex,
setCellProps,
isDetails,
}): React.ReactElement | null => {
const pageRowIndex = getPageRowIndex(rowIndex, pagination.pageSize);
const rowData = pageRowIndex < data.length ? data[pageRowIndex].data : null;
const header = columnHeaders.find((h) => h.id === columnId);
const eventId = pageRowIndex < data.length ? data[pageRowIndex]._id : null;
const ecs = pageRowIndex < data.length ? data[pageRowIndex].ecs : null;
useEffect(() => {
const defaultStyles = { overflow: 'hidden' };
setCellProps({ style: { ...defaultStyles } });
if (ecs && rowData) {
addBuildingBlockStyle(ecs, theme, setCellProps, defaultStyles);
} else {
// disable the cell when it has no data
setCellProps({ style: { display: 'none' } });
}
}, [rowIndex, setCellProps, ecs, rowData]);
if (rowData == null || header == null || eventId == null || ecs === null) {
return null;
}
return renderCellValue({
browserFields,
columnId: header.id,
data: rowData,
ecsData: ecs,
eventId,
globalFilters: filters,
header,
isDetails,
isDraggable: false,
isExpandable: true,
isExpanded: false,
linkValues: getOr([], header.linkField ?? '', ecs),
rowIndex,
colIndex,
rowRenderers,
setCellProps,
scopeId: id,
truncate: isDetails ? false : true,
}) as React.ReactElement;
};
return Cell;
}, [
browserFields,
columnHeaders,
data,
filters,
id,
pagination.pageSize,
renderCellValue,
rowRenderers,
theme,
]);
return (
<>
<EuiDataGridContainer hideLastPage={totalItems > ES_LIMIT_COUNT}>
<EuiDataGrid
id={'body-data-grid'}
data-test-subj="body-data-grid"
aria-label={DATA_TABLE_ARIA_LABEL}
columns={columnsWithCellActions}
columnVisibility={{ visibleColumns, setVisibleColumns: onSetVisibleColumns }}
gridStyle={gridStyle}
leadingControlColumns={leadingControlColumns}
toolbarVisibility={toolbarVisibility}
rowCount={totalItems}
renderCellValue={renderTGridCellValue}
sorting={{ columns: sortingColumns, onSort }}
onColumnResize={onColumnResize}
pagination={pagination}
ref={dataGridRef}
/>
</EuiDataGridContainer>
</>
);
}
);
DataTableComponent.displayName = 'DataTableComponent';

View file

@ -5,18 +5,8 @@
* 2.0.
*/
import type { Filter } from '@kbn/es-query';
import {
ColumnHeaderOptions,
ColumnId,
RowRendererId,
Sort,
DataExpandedDetail,
TimelineTypeLiteral,
} from '.';
import { Direction } from '../../search_strategy';
import { DataProvider } from './data_provider';
import type { ColumnHeaderOptions, ColumnId } from '../../../../common/types';
import type { SortDirectionTable as SortDirection } from '../../../../common/types/data_table';
export type KueryFilterQueryKind = 'kuery' | 'lucene' | 'eql';
@ -30,38 +20,6 @@ export interface SerializedFilterQuery {
serializedQuery: string;
}
export type SortDirection = 'none' | 'asc' | 'desc' | Direction;
export interface SortColumnTable {
columnId: string;
columnType: string;
esTypes?: string[];
sortDirection: SortDirection;
}
export interface TimelinePersistInput {
id: string;
dataProviders?: DataProvider[];
dateRange?: {
start: string;
end: string;
};
excludedRowRendererIds?: RowRendererId[];
expandedDetail?: DataExpandedDetail;
filters?: Filter[];
columns: ColumnHeaderOptions[];
itemsPerPage?: number;
indexNames: string[];
kqlQuery?: {
filterQuery: SerializedFilterQuery | null;
};
show?: boolean;
sort?: Sort[];
showCheckboxes?: boolean;
timelineType?: TimelineTypeLiteral;
templateTimelineId?: string | null;
templateTimelineVersion?: number | null;
}
/** Invoked when a column is sorted */
export type OnColumnSorted = (sorted: { columnId: ColumnId; sortDirection: SortDirection }) => void;
@ -90,9 +48,3 @@ export type OnSelectAll = ({ isSelected }: { isSelected: boolean }) => void;
/** Invoked when columns are updated */
export type OnUpdateColumns = (columns: ColumnHeaderOptions[]) => void;
/** Invoked when a user pins an event */
export type OnPinEvent = (eventId: string) => void;
/** Invoked when a user unpins an event */
export type OnUnPinEvent = (eventId: string) => void;

View file

@ -14,10 +14,6 @@ import type { Dispatch } from 'redux';
import deepEqual from 'fast-deep-equal';
import { IS_DRAGGING_CLASS_NAME } from '@kbn/securitysolution-t-grid';
import {
addFieldToTimelineColumns,
getTimelineIdFromColumnDroppableId,
} from '@kbn/timelines-plugin/public';
import type { BeforeCapture } from './drag_drop_context';
import type { BrowserFields } from '../../containers/source';
import { dragAndDropSelectors } from '../../store';
@ -38,6 +34,8 @@ import {
providerWasDroppedOnTimeline,
draggableIsField,
userIsReArrangingProviders,
getIdFromColumnDroppableId,
addFieldToColumns,
} from './helpers';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { useKibana } from '../../lib/kibana';
@ -87,12 +85,12 @@ const onDragEndHandler = ({
timelineId: TimelineId.active,
});
} else if (fieldWasDroppedOnTimelineColumns(result)) {
addFieldToTimelineColumns({
addFieldToColumns({
browserFields,
defaultsHeader: defaultAlertsHeaders,
dispatch,
result,
timelineId: getTimelineIdFromColumnDroppableId(result.destination?.droppableId ?? ''),
scopeId: getIdFromColumnDroppableId(result.destination?.droppableId ?? ''),
});
}
};

View file

@ -0,0 +1,35 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { draggableKeyDownHandler } from './helpers';
jest.mock('../../../lib/kibana');
describe('draggableKeyDownHandler', () => {
test('it calles the proper function cancelDragActions when Escape key was pressed', () => {
const mockElement = document.createElement('div');
const keyboardEvent = new KeyboardEvent('keydown', {
ctrlKey: false,
key: 'Escape',
metaKey: false,
}) as unknown as React.KeyboardEvent;
const cancelDragActions = jest.fn();
draggableKeyDownHandler({
closePopover: jest.fn(),
openPopover: jest.fn(),
beginDrag: jest.fn(),
cancelDragActions,
draggableElement: mockElement,
dragActions: null,
dragToLocation: jest.fn(),
endDrag: jest.fn(),
keyboardEvent,
setDragActions: jest.fn(),
});
expect(cancelDragActions).toBeCalled();
});
});

View file

@ -5,16 +5,10 @@
* 2.0.
*/
import type { DropResult, FluidDragActions, Position } from 'react-beautiful-dnd';
import { KEYBOARD_DRAG_OFFSET, getFieldIdFromDraggable } from '@kbn/securitysolution-t-grid';
import { Dispatch } from 'redux';
import { isString, keyBy } from 'lodash/fp';
import type { FluidDragActions, Position } from 'react-beautiful-dnd';
import { KEYBOARD_DRAG_OFFSET } from '@kbn/securitysolution-t-grid';
import { stopPropagationAndPreventDefault } from '../../../common/utils/accessibility';
import type { BrowserField, BrowserFields } from '../../../common/search_strategy';
import type { ColumnHeaderOptions } from '../../../common/types';
import { TableId, tGridActions } from '../../store/t_grid';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../t_grid/body/constants';
import { stopPropagationAndPreventDefault } from '@kbn/timelines-plugin/public';
/**
* Temporarily disables tab focus on child links of the draggable to work
@ -130,82 +124,3 @@ export const draggableKeyDownHandler = ({
break;
}
};
const getAllBrowserFields = (browserFields: BrowserFields): Array<Partial<BrowserField>> =>
Object.values(browserFields).reduce<Array<Partial<BrowserField>>>(
(acc, namespace) => [
...acc,
...Object.values(namespace.fields != null ? namespace.fields : {}),
],
[]
);
const getAllFieldsByName = (
browserFields: BrowserFields
): { [fieldName: string]: Partial<BrowserField> } =>
keyBy('name', getAllBrowserFields(browserFields));
const linkFields: Record<string, string> = {
'kibana.alert.rule.name': 'kibana.alert.rule.uuid',
'event.module': 'rule.reference',
};
interface AddFieldToTimelineColumnsParams {
defaultsHeader: ColumnHeaderOptions[];
browserFields: BrowserFields;
dispatch: Dispatch;
result: DropResult;
timelineId: string;
}
export const addFieldToTimelineColumns = ({
browserFields,
dispatch,
result,
timelineId,
defaultsHeader,
}: AddFieldToTimelineColumnsParams): void => {
const fieldId = getFieldIdFromDraggable(result);
const allColumns = getAllFieldsByName(browserFields);
const column = allColumns[fieldId];
const initColumnHeader =
timelineId === TableId.alertsOnAlertsPage || timelineId === TableId.alertsOnRuleDetailsPage
? defaultsHeader.find((c) => c.id === fieldId) ?? {}
: {};
if (column != null) {
dispatch(
tGridActions.upsertColumn({
column: {
category: column.category,
columnHeaderType: 'not-filtered',
description: isString(column.description) ? column.description : undefined,
example: isString(column.example) ? column.example : undefined,
id: fieldId,
linkField: linkFields[fieldId] ?? undefined,
type: column.type,
aggregatable: column.aggregatable,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
...initColumnHeader,
},
id: timelineId,
index: result.destination != null ? result.destination.index : 0,
})
);
} else {
// create a column definition, because it doesn't exist in the browserFields:
dispatch(
tGridActions.upsertColumn({
column: {
columnHeaderType: 'not-filtered',
id: fieldId,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
},
id: timelineId,
index: result.destination != null ? result.destination.index : 0,
})
);
}
};
export const getTimelineIdFromColumnDroppableId = (droppableId: string) =>
droppableId.slice(droppableId.lastIndexOf('.') + 1);

View file

@ -5,12 +5,11 @@
* 2.0.
*/
import React, { useCallback, useMemo, useState } from 'react';
import type React from 'react';
import { useCallback, useMemo, useState } from 'react';
import type { FluidDragActions } from 'react-beautiful-dnd';
import { useAddToTimeline } from '../../../hooks/use_add_to_timeline';
import { draggableKeyDownHandler } from '../helpers';
import { useKibana } from '../../../lib/kibana';
import { draggableKeyDownHandler } from './helpers';
export interface UseDraggableKeyboardWrapperProps {
closePopover?: () => void;
@ -32,6 +31,8 @@ export const useDraggableKeyboardWrapper = ({
keyboardHandlerRef,
openPopover,
}: UseDraggableKeyboardWrapperProps): UseDraggableKeyboardWrapper => {
const { timelines } = useKibana().services;
const useAddToTimeline = timelines.getUseAddToTimeline();
const { beginDrag, cancelDrag, dragToLocation, endDrag, hasDraggableLock } = useAddToTimeline({
draggableId,
fieldName,

View file

@ -30,8 +30,8 @@ import { getDraggableId, getDroppableId } from './helpers';
import { ProviderContainer } from './provider_container';
import * as i18n from './translations';
import { useKibana } from '../../lib/kibana';
import { useHoverActions } from '../hover_actions/use_hover_actions';
import { useDraggableKeyboardWrapper } from './draggable_keyboard_wrapper_hook';
// As right now, we do not know what we want there, we will keep it as a placeholder
export const DragEffects = styled.div``;
@ -144,7 +144,6 @@ const DraggableOnWrapperComponent: React.FC<Props> = ({
const [providerRegistered, setProviderRegistered] = useState(false);
const isDisabled = dataProvider.id.includes(`-${ROW_RENDERER_BROWSER_EXAMPLE_TIMELINE_ID}-`);
const dispatch = useDispatch();
const { timelines } = useKibana().services;
const {
closePopOverTrigger,
handleClosePopOverTrigger,
@ -248,7 +247,7 @@ const DraggableOnWrapperComponent: React.FC<Props> = ({
[dataProvider, registerProvider, render, setContainerRef, truncate]
);
const { onBlur, onKeyDown } = timelines.getUseDraggableKeyboardWrapper()({
const { onBlur, onKeyDown } = useDraggableKeyboardWrapper({
closePopover: handleClosePopOverTrigger,
draggableId: getDraggableId(dataProvider.id),
fieldName: dataProvider.queryMatch.field,

View file

@ -7,7 +7,6 @@
import { omit } from 'lodash/fp';
import type { DropResult } from 'react-beautiful-dnd';
import { getTimelineIdFromColumnDroppableId } from '@kbn/timelines-plugin/public';
import type { IdToDataProvider } from '../../store/drag_and_drop/model';
@ -998,16 +997,4 @@ describe('helpers', () => {
});
});
});
describe('getTimelineIdFromColumnDroppableId', () => {
test('it returns the expected timelineId from a column droppableId', () => {
expect(getTimelineIdFromColumnDroppableId(DROPPABLE_ID_TIMELINE_COLUMNS)).toEqual(
'timeline-1'
);
});
test('it returns an empty string when the droppableId is an empty string', () => {
expect(getTimelineIdFromColumnDroppableId('')).toEqual('');
});
});
});

View file

@ -4,11 +4,17 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isString, keyBy } from 'lodash/fp';
import type { DropResult } from 'react-beautiful-dnd';
import type { Dispatch } from 'redux';
import type { ActionCreator } from 'typescript-fsa';
import { getProviderIdFromDraggable } from '@kbn/securitysolution-t-grid';
import { getFieldIdFromDraggable, getProviderIdFromDraggable } from '@kbn/securitysolution-t-grid';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants';
import { getScopedActions } from '../../../helpers';
import type { ColumnHeaderOptions } from '../../../../common/types';
import { TableId } from '../../../../common/types';
import type { BrowserField, BrowserFields } from '../../../../common/search_strategy';
import { dragAndDropActions } from '../../store/actions';
import type { IdToDataProvider } from '../../store/drag_and_drop/model';
import { addContentToTimeline } from '../../../timelines/components/timeline/data_providers/helpers';
@ -177,3 +183,84 @@ export const allowTopN = ({
return isAllowlistedNonBrowserField || (isAggregatable && isAllowedType);
};
const getAllBrowserFields = (browserFields: BrowserFields): Array<Partial<BrowserField>> =>
Object.values(browserFields).reduce<Array<Partial<BrowserField>>>(
(acc, namespace) => [
...acc,
...Object.values(namespace.fields != null ? namespace.fields : {}),
],
[]
);
const getAllFieldsByName = (
browserFields: BrowserFields
): { [fieldName: string]: Partial<BrowserField> } =>
keyBy('name', getAllBrowserFields(browserFields));
const linkFields: Record<string, string> = {
'kibana.alert.rule.name': 'kibana.alert.rule.uuid',
'event.module': 'rule.reference',
};
interface AddFieldToTimelineColumnsParams {
defaultsHeader: ColumnHeaderOptions[];
browserFields: BrowserFields;
dispatch: Dispatch;
result: DropResult;
scopeId: string;
}
export const addFieldToColumns = ({
browserFields,
dispatch,
result,
scopeId,
defaultsHeader,
}: AddFieldToTimelineColumnsParams): void => {
const fieldId = getFieldIdFromDraggable(result);
const allColumns = getAllFieldsByName(browserFields);
const column = allColumns[fieldId];
const initColumnHeader =
scopeId === TableId.alertsOnAlertsPage || scopeId === TableId.alertsOnRuleDetailsPage
? defaultsHeader.find((c) => c.id === fieldId) ?? {}
: {};
const scopedActions = getScopedActions(scopeId);
if (column != null && scopedActions) {
dispatch(
scopedActions.upsertColumn({
column: {
category: column.category,
columnHeaderType: 'not-filtered',
description: isString(column.description) ? column.description : undefined,
example: isString(column.example) ? column.example : undefined,
id: fieldId,
linkField: linkFields[fieldId] ?? undefined,
type: column.type,
aggregatable: column.aggregatable,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
...initColumnHeader,
},
id: scopeId,
index: result.destination != null ? result.destination.index : 0,
})
);
} else if (scopedActions) {
// create a column definition, because it doesn't exist in the browserFields:
dispatch(
scopedActions.upsertColumn({
column: {
columnHeaderType: 'not-filtered',
id: fieldId,
initialWidth: DEFAULT_COLUMN_MIN_WIDTH,
},
id: scopeId,
index: result.destination != null ? result.destination.index : 0,
})
);
}
};
export const getIdFromColumnDroppableId = (droppableId: string) =>
droppableId.slice(droppableId.lastIndexOf('.') + 1);

View file

@ -19,27 +19,14 @@ import { createStore } from '../../store/store';
import { ErrorToastDispatcher } from '.';
import type { State } from '../../store/types';
import { tGridReducer } from '@kbn/timelines-plugin/public';
describe('Error Toast Dispatcher', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
beforeEach(() => {
store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
});
describe('rendering', () => {

View file

@ -19,7 +19,6 @@ import { SeverityBadge } from '../../../../detections/components/rules/severity_
import type { State } from '../../../store';
import { createStore } from '../../../store';
import { TimelineId } from '../../../../../common/types';
import { tGridReducer } from '@kbn/timelines-plugin/public';
const state: State = {
...mockGlobalState,
@ -35,13 +34,7 @@ const state: State = {
};
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const props = {
title: 'Severity',

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isEmpty } from 'lodash/fp';
import type { Ecs } from '../../../../common/ecs';
export const isEventBuildingBlockType = (event: Ecs): boolean =>
!isEmpty(event.kibana?.alert?.building_block_type);
/** This local storage key stores the `Grid / Event rendered view` selection */
export const ALERTS_TABLE_VIEW_SELECTION_KEY = 'securitySolution.alerts.table.view-selection';

View file

@ -7,9 +7,26 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { eventRenderedProps, TestProviders } from '../../../mock';
import type { EventRenderedViewProps } from '.';
import { EventRenderedView } from '.';
import { RowRendererId } from '../../../../common/types';
import { RowRendererId, TableId } from '../../../../common/types';
import { mockTimelineData, TestProviders } from '../../mock';
const eventRenderedProps: EventRenderedViewProps = {
events: mockTimelineData,
leadingControlColumns: [],
onChangePage: () => null,
onChangeItemsPerPage: () => null,
pagination: {
pageIndex: 0,
pageSize: 10,
pageSizeOptions: [10, 25, 50, 100],
totalItemCount: 100,
},
rowRenderers: [],
scopeId: TableId.alertsOnAlertsPage,
unitCountText: '10 events',
};
describe('event_rendered_view', () => {
beforeEach(() => jest.clearAllMocks());
@ -21,7 +38,7 @@ describe('event_rendered_view', () => {
</TestProviders>
);
expect(screen.queryAllByTestId('moment-date')[0].textContent).toEqual(
'2018-11-05T14:03:25-05:00'
'Nov 5, 2018 @ 19:03:25.937'
);
});

View file

@ -4,29 +4,31 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import {
import type {
CriteriaWithPagination,
EuiBasicTable,
EuiBasicTableProps,
EuiDataGridCellValueElementProps,
EuiDataGridControlColumn,
EuiFlexGroup,
EuiFlexItem,
Pagination,
} from '@elastic/eui';
import { EuiBasicTable, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ALERT_REASON, ALERT_RULE_NAME, ALERT_RULE_UUID } from '@kbn/rule-data-utils';
import { get } from 'lodash';
import moment from 'moment';
import React, { ComponentType, useCallback, useMemo } from 'react';
import type { ComponentType } from 'react';
import React, { useCallback, useMemo } from 'react';
import styled from 'styled-components';
import { useUiSetting } from '@kbn/kibana-react-plugin/public';
import { Ecs } from '../../../../common/ecs';
import type { Ecs } from '../../../../common/ecs';
import { APP_UI_ID } from '../../../../common/constants';
import type { TimelineItem } from '../../../../common/search_strategy';
import type { RowRenderer } from '../../../../common/types';
import { RuleName } from '../../rule_name';
import { isEventBuildingBlockType } from '../body/helpers';
import { RuleName } from '../rule_name';
import { isEventBuildingBlockType } from './helpers';
import { UnitCount } from '../toolbar/unit';
const EventRenderedFlexItem = styled(EuiFlexItem)`
div:first-child {
@ -68,9 +70,15 @@ const StyledEuiBasicTable = styled(EuiBasicTable as BasicTableType)`
`;
export interface EventRenderedViewProps {
alertToolbar: React.ReactNode;
appId: string;
events: TimelineItem[];
leadingControlColumns: EuiDataGridControlColumn[];
onChangePage: (newActivePage: number) => void;
onChangeItemsPerPage: (newItemsPerPage: number) => void;
rowRenderers: RowRenderer[];
scopeId: string;
pagination: Pagination;
unitCountText: string;
additionalControls?: React.ReactNode;
getRowRenderer?: ({
data,
rowRenderers,
@ -78,16 +86,8 @@ export interface EventRenderedViewProps {
data: Ecs;
rowRenderers: RowRenderer[];
}) => RowRenderer | null;
leadingControlColumns: EuiDataGridControlColumn[];
onChangePage: (newActivePage: number) => void;
onChangeItemsPerPage: (newItemsPerPage: number) => void;
pageIndex: number;
pageSize: number;
pageSizeOptions: number[];
rowRenderers: RowRenderer[];
timelineId: string;
totalItemCount: number;
}
const PreferenceFormattedDateComponent = ({ value }: { value: Date }) => {
const tz = useUiSetting<string>('dateFormat:tz');
const dateFormat = useUiSetting<string>('dateFormat');
@ -98,19 +98,16 @@ const PreferenceFormattedDateComponent = ({ value }: { value: Date }) => {
export const PreferenceFormattedDate = React.memo(PreferenceFormattedDateComponent);
const EventRenderedViewComponent = ({
alertToolbar,
appId,
additionalControls,
events,
getRowRenderer,
leadingControlColumns,
onChangePage,
onChangeItemsPerPage,
pageIndex,
pageSize,
pageSizeOptions,
rowRenderers,
timelineId,
totalItemCount,
scopeId,
pagination,
unitCountText,
}: EventRenderedViewProps) => {
const ActionTitle = useMemo(
() => (
@ -163,7 +160,7 @@ const EventRenderedViewComponent = ({
},
{
field: 'ecs.timestamp',
name: i18n.translate('xpack.timelines.alerts.EventRenderedView.timestamp.column', {
name: i18n.translate('xpack.securitySolution.EventRenderedView.timestamp.column', {
defaultMessage: 'Timestamp',
}),
truncateText: false,
@ -175,7 +172,7 @@ const EventRenderedViewComponent = ({
},
{
field: `ecs.${ALERT_RULE_NAME}`,
name: i18n.translate('xpack.timelines.alerts.EventRenderedView.rule.column', {
name: i18n.translate('xpack.securitySolution.EventRenderedView.rule.column', {
defaultMessage: 'Rule',
}),
truncateText: false,
@ -183,12 +180,12 @@ const EventRenderedViewComponent = ({
render: (name: unknown, item: TimelineItem) => {
const ruleName = get(item, `ecs.signal.rule.name`) ?? get(item, `ecs.${ALERT_RULE_NAME}`);
const ruleId = get(item, `ecs.signal.rule.id`) ?? get(item, `ecs.${ALERT_RULE_UUID}`);
return <RuleName name={ruleName} id={ruleId} appId={appId} />;
return <RuleName name={ruleName} id={ruleId} appId={APP_UI_ID} />;
},
},
{
field: 'eventSummary',
name: i18n.translate('xpack.timelines.alerts.EventRenderedView.eventSummary.column', {
name: i18n.translate('xpack.securitySolution.EventRenderedView.eventSummary.column', {
defaultMessage: 'Event Summary',
}),
truncateText: false,
@ -209,7 +206,7 @@ const EventRenderedViewComponent = ({
{rowRenderer.renderRow({
data: ecsData,
isDraggable: false,
scopeId: timelineId,
scopeId,
})}
</div>
</EventRenderedFlexItem>
@ -224,35 +221,36 @@ const EventRenderedViewComponent = ({
width: '60%',
},
],
[ActionTitle, events, leadingControlColumns, appId, getRowRenderer, rowRenderers, timelineId]
[ActionTitle, events, getRowRenderer, leadingControlColumns, rowRenderers, scopeId]
);
const handleTableChange = useCallback(
(pageChange: CriteriaWithPagination<TimelineItem>) => {
if (pageChange.page.index !== pageIndex) {
if (pageChange.page.index !== pagination.pageIndex) {
onChangePage(pageChange.page.index);
}
if (pageChange.page.size !== pageSize) {
if (pageChange.page.size !== pagination.pageSize) {
onChangeItemsPerPage(pageChange.page.size);
}
},
[onChangePage, pageIndex, pageSize, onChangeItemsPerPage]
[pagination.pageIndex, pagination.pageSize, onChangePage, onChangeItemsPerPage]
);
const pagination = useMemo(
() => ({
pageIndex,
pageSize,
totalItemCount,
pageSizeOptions,
showPerPageOptions: true,
}),
[pageIndex, pageSize, pageSizeOptions, totalItemCount]
const toolbar = useMemo(
() => (
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>
<UnitCount data-test-subj="server-side-event-count">{unitCountText}</UnitCount>
</EuiFlexItem>
{additionalControls}
</EuiFlexGroup>
),
[additionalControls, unitCountText]
);
return (
<>
{alertToolbar}
{toolbar}
<StyledEuiBasicTable
compressed
items={events}

View file

@ -13,9 +13,9 @@ import { TestProviders } from '../../mock';
import type { EventsQueryTabBodyComponentProps } from './events_query_tab_body';
import { EventsQueryTabBody, ALERTS_EVENTS_HISTOGRAM_ID } from './events_query_tab_body';
import { useGlobalFullScreen } from '../../containers/use_full_screen';
import * as tGridActions from '@kbn/timelines-plugin/public/store/t_grid/actions';
import { licenseService } from '../../hooks/use_license';
import { mockHistory } from '../../mock/router';
import { dataTableActions } from '../../store/data_table';
const mockGetDefaultControlColumn = jest.fn();
jest.mock('../../../timelines/components/timeline/body/control_columns', () => ({
@ -172,7 +172,7 @@ describe('EventsQueryTabBody', () => {
});
it('initializes t-grid', () => {
const spy = jest.spyOn(tGridActions, 'initializeTGridSettings');
const spy = jest.spyOn(dataTableActions, 'initializeDataTableSettings');
render(
<TestProviders>
<EventsQueryTabBody {...commonProps} />

View file

@ -10,11 +10,8 @@ import { useDispatch } from 'react-redux';
import { EuiCheckbox } from '@elastic/eui';
import type { Filter } from '@kbn/es-query';
import type { EntityType } from '@kbn/timelines-plugin/common';
import type { BulkActionsProp } from '@kbn/timelines-plugin/common/types';
import type { TableId } from '../../../../common/types';
import { dataTableActions } from '../../store/data_table';
import type { TableId } from '../../../../common/types/timeline';
import { RowRendererId } from '../../../../common/types/timeline';
import { StatefulEventsViewer } from '../events_viewer';
import { eventsDefaultModel } from '../events_viewer/default_model';
@ -48,6 +45,7 @@ import {
useGetInitialUrlParamValue,
useReplaceUrlParams,
} from '../../utils/global_query_string/helpers';
import type { BulkActionsProp } from '../toolbar/bulk_actions/types';
export const ALERTS_EVENTS_HISTOGRAM_ID = 'alertsOrEventsHistogramQuery';
@ -100,7 +98,7 @@ const EventsQueryTabBodyComponent: React.FC<EventsQueryTabBodyComponentProps> =
useEffect(() => {
dispatch(
dataTableActions.initializeTGridSettings({
dataTableActions.initializeDataTableSettings({
id: tableId,
defaultColumns: eventsDefaultModel.columns.map((c) =>
!tGridEnabled && c.initialWidth == null
@ -187,13 +185,12 @@ const EventsQueryTabBodyComponent: React.FC<EventsQueryTabBodyComponentProps> =
defaultCellActions={defaultCellActions}
start={startDate}
end={endDate}
entityType={'events' as EntityType}
leadingControlColumns={leadingControlColumns}
renderCellValue={DefaultCellRenderer}
rowRenderers={defaultRowRenderers}
scopeId={SourcererScopeName.default}
sourcererScope={SourcererScopeName.default}
tableId={tableId}
unit={showExternalAlerts ? i18n.ALERTS_UNIT : i18n.EVENTS_UNIT}
unit={showExternalAlerts ? i18n.EXTERNAL_ALERTS_UNIT : i18n.EVENTS_UNIT}
defaultModel={defaultModel}
pageFilters={composedPageFilters}
bulkActions={bulkActions}

View file

@ -17,7 +17,7 @@ const DEFAULT_EVENTS_STACK_BY = 'event.action';
export const getSubtitleFunction =
(defaultNumberFormat: string, isAlert: boolean) => (totalCount: number) =>
`${i18n.SHOWING}: ${numeral(totalCount).format(defaultNumberFormat)} ${
isAlert ? i18n.ALERTS_UNIT(totalCount) : i18n.EVENTS_UNIT(totalCount)
isAlert ? i18n.EXTERNAL_ALERTS_UNIT(totalCount) : i18n.EVENTS_UNIT(totalCount)
}`;
export const eventsStackByOptions: MatrixHistogramOption[] = [

View file

@ -7,8 +7,8 @@
import { i18n } from '@kbn/i18n';
export const ALERTS_UNIT = (totalCount: number) =>
i18n.translate('xpack.securitySolution.eventsTab.unit', {
export const EXTERNAL_ALERTS_UNIT = (totalCount: number) =>
i18n.translate('xpack.securitySolution.eventsTab.externalAlertsUnit', {
values: { totalCount },
defaultMessage: `external {totalCount, plural, =1 {alert} other {alerts}}`,
});

View file

@ -6,10 +6,10 @@
*/
import { tableDefaults } from '../../store/data_table/defaults';
import type { SubsetTGridModel } from '../../store/data_table/model';
import type { SubsetDataTableModel } from '../../store/data_table/model';
import { defaultEventHeaders } from './default_event_headers';
export const eventsDefaultModel: SubsetTGridModel = {
export const eventsDefaultModel: SubsetDataTableModel = {
...tableDefaults,
columns: defaultEventHeaders,
};

View file

@ -0,0 +1,65 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { TableId } from '../../../../common/types';
import type { CombineQueries } from '../../lib/kuery';
import { buildTimeRangeFilter, combineQueries } from '../../lib/kuery';
import { EVENTS_TABLE_CLASS_NAME } from './styles';
import type { ViewSelection } from './summary_view_select';
export const getCombinedFilterQuery = ({
from,
to,
filters,
...combineQueriesParams
}: CombineQueries & { from: string; to: string }): string | undefined => {
const combinedQueries = combineQueries({
...combineQueriesParams,
filters: [...filters, buildTimeRangeFilter(from, to)],
});
return combinedQueries ? combinedQueries.filterQuery : undefined;
};
export const resolverIsShowing = (graphEventId: string | undefined): boolean =>
graphEventId != null && graphEventId !== '';
export const EVENTS_COUNT_BUTTON_CLASS_NAME = 'local-events-count-button';
/** Returns `true` when the element, or one of it's children has focus */
export const elementOrChildrenHasFocus = (element: HTMLElement | null | undefined): boolean =>
element === document.activeElement || element?.querySelector(':focus-within') != null;
/** Returns true if the events table has focus */
export const tableHasFocus = (containerElement: HTMLElement | null): boolean =>
elementOrChildrenHasFocus(
containerElement?.querySelector<HTMLDivElement>(`.${EVENTS_TABLE_CLASS_NAME}`)
);
export const isSelectableView = (tableId: string): boolean =>
tableId === TableId.alertsOnAlertsPage || tableId === TableId.alertsOnRuleDetailsPage;
export const isViewSelection = (value: unknown): value is ViewSelection =>
value === 'gridView' || value === 'eventRenderedView';
/** always returns a valid default `ViewSelection` */
export const getDefaultViewSelection = ({
tableId,
value,
}: {
tableId: string;
value: unknown;
}): ViewSelection => {
const defaultViewSelection = 'gridView';
if (!isSelectableView(tableId)) {
return defaultViewSelection;
} else {
return isViewSelection(value) ? value : defaultViewSelection;
}
};

View file

@ -16,7 +16,6 @@ import { mockEventViewerResponse } from './mock';
import { StatefulEventsViewer } from '.';
import { eventsDefaultModel } from './default_model';
import { EntityType } from '@kbn/timelines-plugin/common';
import { TableId } from '../../../../common/types/timeline';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { DefaultCellRenderer } from '../../../timelines/components/timeline/cell_rendering/default_cell_renderer';
import { useTimelineEvents } from '../../../timelines/containers';
@ -25,9 +24,21 @@ import { defaultRowRenderers } from '../../../timelines/components/timeline/body
import { defaultCellActions } from '../../lib/cell_actions/default_cell_actions';
import type { UseFieldBrowserOptionsProps } from '../../../timelines/components/fields_browser';
import { useGetUserCasesPermissions } from '../../lib/kibana';
import { TableId } from '../../../../common/types';
import { mount } from 'enzyme';
jest.mock('../../lib/kibana');
const mockDispatch = jest.fn();
jest.mock('react-redux', () => {
const original = jest.requireActual('react-redux');
return {
...original,
useDispatch: () => mockDispatch,
};
});
const originalKibanaLib = jest.requireActual('../../lib/kibana');
// Restore the useGetUserCasesPermissions so the calling functions can receive a valid permissions object
@ -46,6 +57,12 @@ jest.mock('../../../timelines/components/fields_browser', () => ({
useFieldBrowserOptions: (props: UseFieldBrowserOptionsProps) => mockUseFieldBrowserOptions(props),
}));
jest.mock('./helpers', () => ({
getDefaultViewSelection: () => 'gridView',
resolverIsShowing: () => false,
getCombinedFilterQuery: () => undefined,
}));
const mockUseResizeObserver: jest.Mock = useResizeObserver as jest.Mock;
jest.mock('use-resize-observer/polyfilled');
mockUseResizeObserver.mockImplementation(() => ({}));
@ -64,35 +81,36 @@ const testProps = {
leadingControlColumns: getDefaultControlColumn(ACTION_BUTTON_COUNT),
renderCellValue: DefaultCellRenderer,
rowRenderers: defaultRowRenderers,
scopeId: SourcererScopeName.default,
sourcererScope: SourcererScopeName.default,
start: from,
bulkActions: false,
hasCrudPermissions: true,
};
describe('StatefulEventsViewer', () => {
(useTimelineEvents as jest.Mock).mockReturnValue([false, mockEventViewerResponse]);
test('it renders the events viewer', async () => {
const wrapper = render(
test('it renders the events viewer', () => {
const wrapper = mount(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
expect(wrapper.getByText('hello grid')).toBeTruthy();
expect(wrapper.find(`[data-test-subj="events-viewer-panel"]`).exists()).toBeTruthy();
});
// InspectButtonContainer controls displaying InspectButton components
test('it renders InspectButtonContainer', async () => {
const wrapper = render(
test('it renders InspectButtonContainer', () => {
const wrapper = mount(
<TestProviders>
<StatefulEventsViewer {...testProps} />
</TestProviders>
);
expect(wrapper.getByTestId(`hoverVisibilityContainer`)).toBeTruthy();
expect(wrapper.find(`[data-test-subj="hoverVisibilityContainer"]`).exists()).toBeTruthy();
});
test('it closes field editor when unmounted', async () => {
test('it closes field editor when unmounted', () => {
const mockCloseEditor = jest.fn();
mockUseFieldBrowserOptions.mockImplementation(({ editorActionsRef }) => {
editorActionsRef.current = { closeEditor: mockCloseEditor };

View file

@ -5,65 +5,97 @@
* 2.0.
*/
import React, { useRef, useCallback, useMemo, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import styled from 'styled-components';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { AlertConsumers } from '@kbn/rule-data-utils';
import React, { useRef, useCallback, useMemo, useEffect, useState, useContext } from 'react';
import type { ConnectedProps } from 'react-redux';
import { connect, useDispatch, useSelector } from 'react-redux';
import { ThemeContext } from 'styled-components';
import type { Filter } from '@kbn/es-query';
import type { EntityType, RowRenderer } from '@kbn/timelines-plugin/common';
import type { TGridCellAction, BulkActionsProp } from '@kbn/timelines-plugin/common/types';
import type { ControlColumnProps, TableId } from '../../../../common/types';
import type { Direction, EntityType, RowRenderer } from '@kbn/timelines-plugin/common';
import { isEmpty } from 'lodash';
import { getEsQueryConfig } from '@kbn/data-plugin/common';
import type { EuiTheme } from '@kbn/kibana-react-plugin/common';
import { getRowRenderer } from '../../../timelines/components/timeline/body/renderers/get_row_renderer';
import type { Sort } from '../../../timelines/components/timeline/body/sort';
import type {
ControlColumnProps,
DataTableCellAction,
OnRowSelected,
OnSelectAll,
SetEventsDeleted,
SetEventsLoading,
TableId,
} from '../../../../common/types';
import { dataTableActions } from '../../store/data_table';
import { InputsModelId } from '../../store/inputs/constants';
import type { State } from '../../store';
import { inputsActions } from '../../store/actions';
import { APP_UI_ID } from '../../../../common/constants';
import type { Status } from '../../../../common/detection_engine/schemas/common/schemas';
import { InspectButtonContainer } from '../inspect';
import { useGlobalFullScreen } from '../../containers/use_full_screen';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { eventsViewerSelector } from './selectors';
import type { SourcererScopeName } from '../../store/sourcerer/model';
import { useSourcererDataView } from '../../containers/sourcerer';
import type { CellValueElementProps } from '../../../timelines/components/timeline/cell_rendering';
import { FIELDS_WITHOUT_CELL_ACTIONS } from '../../lib/cell_actions/constants';
import { useKibana } from '../../lib/kibana';
import { GraphOverlay } from '../../../timelines/components/graph_overlay';
import type { FieldEditorActions } from '../../../timelines/components/fields_browser';
import { useFieldBrowserOptions } from '../../../timelines/components/fields_browser';
import { getRowRenderer } from '../../../timelines/components/timeline/body/renderers/get_row_renderer';
import {
useSessionViewNavigation,
useSessionView,
} from '../../../timelines/components/timeline/session_tab_content/use_session_view';
import type { SubsetTGridModel } from '../../store/data_table/model';
import type { SubsetDataTableModel } from '../../store/data_table/model';
import {
EventsContainerLoading,
FullScreenContainer,
FullWidthFlexGroupTable,
ScrollableFlexItem,
StyledEuiPanel,
} from './styles';
import { getDefaultViewSelection, getCombinedFilterQuery } from './helpers';
import { ALERTS_TABLE_VIEW_SELECTION_KEY } from '../event_rendered_view/helpers';
import { useTimelineEvents } from './use_timelines_events';
import { TableContext, EmptyTable, TableLoading } from './shared';
import { DataTableComponent } from '../data_table';
import { FIELDS_WITHOUT_CELL_ACTIONS } from '../../lib/cell_actions/constants';
import type { AlertWorkflowStatus } from '../../types';
import { EventRenderedView } from '../event_rendered_view';
import { useQueryInspector } from '../page/manage_query';
import type { SetQuery } from '../../containers/use_global_time/types';
import { defaultHeaders } from '../../store/data_table/defaults';
import { checkBoxControlColumn, transformControlColumns } from '../control_columns';
import { getEventIdToDataMapping } from '../data_table/helpers';
import type { ViewSelection } from './summary_view_select';
import { RightTopMenu } from './right_top_menu';
import { useAlertBulkActions } from './use_alert_bulk_actions';
import type { BulkActionsProp } from '../toolbar/bulk_actions/types';
import { StatefulEventContext } from './stateful_event_context';
import { defaultUnit } from '../toolbar/unit';
const EMPTY_CONTROL_COLUMNS: ControlColumnProps[] = [];
const storage = new Storage(localStorage);
const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>`
height: ${({ $isFullScreen }) => ($isFullScreen ? '100%' : undefined)};
flex: 1 1 auto;
display: flex;
width: 100%;
`;
const SECURITY_ALERTS_CONSUMERS = [AlertConsumers.SIEM];
export interface Props {
defaultCellActions?: TGridCellAction[];
defaultModel: SubsetTGridModel;
export interface EventsViewerProps {
defaultCellActions?: DataTableCellAction[];
defaultModel: SubsetDataTableModel;
end: string;
entityType: EntityType;
entityType?: EntityType;
tableId: TableId;
leadingControlColumns: ControlColumnProps[];
scopeId: SourcererScopeName;
sourcererScope: SourcererScopeName;
start: string;
showTotalCount?: boolean;
pageFilters?: Filter[];
currentFilter?: Status;
currentFilter?: AlertWorkflowStatus;
onRuleChange?: () => void;
renderCellValue: (props: CellValueElementProps) => React.ReactNode;
rowRenderers: RowRenderer[];
additionalFilters?: React.ReactNode;
hasAlertsCrud?: boolean;
hasCrudPermissions?: boolean;
unit?: (n: number) => string;
indexNames?: string[];
bulkActions: boolean | BulkActionsProp;
}
@ -72,11 +104,11 @@ export interface Props {
* timeline is used BESIDES the flyout. The flyout makes use of the `EventsViewer` component which is a subcomponent here
* NOTE: As of writting, it is not used in the Case_View component
*/
const StatefulEventsViewerComponent: React.FC<Props> = ({
const StatefulEventsViewerComponent: React.FC<EventsViewerProps & PropsFromRedux> = ({
defaultCellActions,
defaultModel,
end,
entityType,
entityType = 'events',
tableId,
leadingControlColumns,
pageFilters,
@ -85,15 +117,21 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
renderCellValue,
rowRenderers,
start,
scopeId,
sourcererScope,
additionalFilters,
unit,
hasCrudPermissions = true,
unit = defaultUnit,
indexNames,
bulkActions,
setSelected,
clearSelected,
}) => {
const dispatch = useDispatch();
const theme: EuiTheme = useContext(ThemeContext);
const tableContext = useMemo(() => ({ tableId }), [tableId]);
const {
filters,
input,
query,
dataTable: {
columns,
@ -105,10 +143,23 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
sessionViewConfig,
showCheckboxes,
sort,
queryFields,
selectAll,
selectedEventIds,
isSelectAllChecked,
loadingEventIds,
title,
} = defaultModel,
} = useSelector((state: State) => eventsViewerSelector(state, tableId));
const { timelines: timelinesUi } = useKibana().services;
const { uiSettings, data } = useKibana().services;
const [tableView, setTableView] = useState<ViewSelection>(
getDefaultViewSelection({
tableId,
value: storage.get(ALERTS_TABLE_VIEW_SELECTION_KEY),
})
);
const {
browserFields,
@ -118,28 +169,24 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
selectedPatterns,
dataViewId: selectedDataViewId,
loading: isLoadingIndexPattern,
} = useSourcererDataView(scopeId);
} = useSourcererDataView(sourcererScope);
const { globalFullScreen } = useGlobalFullScreen();
const tGridEventRenderedViewEnabled = useIsExperimentalFeatureEnabled(
'tGridEventRenderedViewEnabled'
);
const editorActionsRef = useRef<FieldEditorActions>(null);
const editorActionsRef = useRef<FieldEditorActions>(null);
useEffect(() => {
dispatch(
dataTableActions.createTGrid({
dataTableActions.createDataTable({
columns,
dataViewId: selectedDataViewId,
defaultColumns,
id: tableId,
indexNames: selectedPatterns,
indexNames: indexNames ?? selectedPatterns,
itemsPerPage,
showCheckboxes,
sort,
})
);
return () => {
dispatch(inputsActions.deleteOneQuery({ id: tableId, inputId: InputsModelId.global }));
if (editorActionsRef.current) {
@ -151,7 +198,6 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
}, []);
const globalFilters = useMemo(() => [...filters, ...(pageFilters ?? [])], [filters, pageFilters]);
const trailingControlColumns: ControlColumnProps[] = EMPTY_CONTROL_COLUMNS;
const { Navigation } = useSessionViewNavigation({
scopeId: tableId,
@ -170,73 +216,386 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
) : null;
}, [graphEventId, tableId, sessionViewConfig, SessionView, Navigation]);
const setQuery = useCallback(
(inspect, loading, refetch) => {
({ id, inspect, loading, refetch }: SetQuery) =>
dispatch(
inputsActions.setQuery({
id: tableId,
id,
inputId: InputsModelId.global,
inspect,
loading,
refetch,
})
);
},
[dispatch, tableId]
),
[dispatch]
);
const fieldBrowserOptions = useFieldBrowserOptions({
sourcererScope: scopeId,
sourcererScope,
editorActionsRef,
upsertColumn: (column, index) =>
dispatch(dataTableActions.upsertColumn({ column, id: tableId, index })),
removeColumn: (columnId) => dispatch(dataTableActions.removeColumn({ columnId, id: tableId })),
});
const isLive = input.policy.kind === 'interval';
const columnHeaders = isEmpty(columns) ? defaultHeaders : columns;
const esQueryConfig = getEsQueryConfig(uiSettings);
const filterQuery = useMemo(
() =>
getCombinedFilterQuery({
config: esQueryConfig,
browserFields,
dataProviders: [],
filters: globalFilters,
from: start,
indexPattern,
kqlMode: 'filter',
kqlQuery: query,
to: end,
}),
[esQueryConfig, browserFields, globalFilters, start, indexPattern, query, end]
);
const canQueryTimeline = useMemo(
() =>
filterQuery != null &&
isLoadingIndexPattern != null &&
!isLoadingIndexPattern &&
!isEmpty(start) &&
!isEmpty(end),
[isLoadingIndexPattern, filterQuery, start, end]
);
const fields = useMemo(
() => [...columnHeaders.map((c: { id: string }) => c.id), ...(queryFields ?? [])],
[columnHeaders, queryFields]
);
const sortField = useMemo(
() =>
(sort as Sort[]).map(({ columnId, columnType, esTypes, sortDirection }) => ({
field: columnId,
type: columnType,
direction: sortDirection as Direction,
esTypes: esTypes ?? [],
})),
[sort]
);
const [loading, { events, loadPage, pageInfo, refetch, totalCount = 0, inspect }] =
useTimelineEvents({
// We rely on entityType to determine Events vs Alerts
alertConsumers: SECURITY_ALERTS_CONSUMERS,
data,
dataViewId,
endDate: end,
entityType,
fields,
filterQuery,
id: tableId,
indexNames: indexNames ?? selectedPatterns,
limit: itemsPerPage,
runtimeMappings,
skip: !canQueryTimeline,
sort: sortField,
startDate: start,
filterStatus: currentFilter,
});
useEffect(() => {
dispatch(dataTableActions.updateIsLoading({ id: tableId, isLoading: loading }));
}, [dispatch, tableId, loading]);
const deleteQuery = useCallback(
({ id }) => dispatch(inputsActions.deleteOneQuery({ inputId: InputsModelId.global, id })),
[dispatch]
);
useQueryInspector({
queryId: tableId,
loading,
refetch,
setQuery,
deleteQuery,
inspect,
});
const totalCountMinusDeleted = useMemo(
() => (totalCount > 0 ? totalCount - deletedEventIds.length : 0),
[deletedEventIds.length, totalCount]
);
const hasAlerts = totalCountMinusDeleted > 0;
// Only show the table-spanning loading indicator when the query is loading and we
// don't have data (e.g. for the initial fetch).
// Subsequent fetches (e.g. for pagination) will show a small loading indicator on
// top of the table and the table will display the current page until the next page
// is fetched. This prevents a flicker when paginating.
const showFullLoading = loading && !hasAlerts;
const nonDeletedEvents = useMemo(
() => events.filter((e) => !deletedEventIds.includes(e._id)),
[deletedEventIds, events]
);
useEffect(() => {
setQuery({ id: tableId, inspect, loading, refetch });
}, [inspect, loading, refetch, setQuery, tableId]);
// Clear checkbox selection when new events are fetched
useEffect(() => {
dispatch(dataTableActions.clearSelected({ id: tableId }));
dispatch(
dataTableActions.setDataTableSelectAll({
id: tableId,
selectAll: false,
})
);
}, [nonDeletedEvents, dispatch, tableId]);
const onChangeItemsPerPage = useCallback(
(itemsChangedPerPage) => {
dispatch(
dataTableActions.updateItemsPerPage({ id: tableId, itemsPerPage: itemsChangedPerPage })
);
},
[tableId, dispatch]
);
const onChangePage = useCallback(
(page) => {
loadPage(page);
},
[loadPage]
);
const setEventsLoading = useCallback<SetEventsLoading>(
({ eventIds, isLoading }) => {
dispatch(dataTableActions.setEventsLoading({ id: tableId, eventIds, isLoading }));
},
[dispatch, tableId]
);
const setEventsDeleted = useCallback<SetEventsDeleted>(
({ eventIds, isDeleted }) => {
dispatch(dataTableActions.setEventsDeleted({ id: tableId, eventIds, isDeleted }));
},
[dispatch, tableId]
);
const selectedCount = useMemo(() => Object.keys(selectedEventIds).length, [selectedEventIds]);
const onRowSelected: OnRowSelected = useCallback(
({ eventIds, isSelected }: { eventIds: string[]; isSelected: boolean }) => {
setSelected({
id: tableId,
eventIds: getEventIdToDataMapping(
nonDeletedEvents,
eventIds,
queryFields,
hasCrudPermissions
),
isSelected,
isSelectAllChecked: isSelected && selectedCount + 1 === nonDeletedEvents.length,
});
},
[setSelected, tableId, nonDeletedEvents, queryFields, hasCrudPermissions, selectedCount]
);
const onSelectPage: OnSelectAll = useCallback(
({ isSelected }: { isSelected: boolean }) =>
isSelected
? setSelected({
id: tableId,
eventIds: getEventIdToDataMapping(
nonDeletedEvents,
nonDeletedEvents.map((event) => event._id),
queryFields,
hasCrudPermissions
),
isSelected,
isSelectAllChecked: isSelected,
})
: clearSelected({ id: tableId }),
[setSelected, tableId, nonDeletedEvents, queryFields, hasCrudPermissions, clearSelected]
);
// Sync to selectAll so parent components can select all events
useEffect(() => {
if (selectAll && !isSelectAllChecked) {
onSelectPage({ isSelected: true });
}
}, [isSelectAllChecked, onSelectPage, selectAll]);
const [transformedLeadingControlColumns] = useMemo(() => {
return [
showCheckboxes ? [checkBoxControlColumn, ...leadingControlColumns] : leadingControlColumns,
].map((controlColumns) =>
transformControlColumns({
columnHeaders,
controlColumns,
data: nonDeletedEvents,
disabledCellActions: FIELDS_WITHOUT_CELL_ACTIONS,
fieldBrowserOptions,
loadingEventIds,
onRowSelected,
onRuleChange,
selectedEventIds,
showCheckboxes,
tabType: 'query',
timelineId: tableId,
isSelectAllChecked,
sort,
browserFields,
onSelectPage,
theme,
setEventsLoading,
setEventsDeleted,
pageSize: itemsPerPage,
})
);
}, [
showCheckboxes,
leadingControlColumns,
columnHeaders,
nonDeletedEvents,
fieldBrowserOptions,
loadingEventIds,
onRowSelected,
onRuleChange,
selectedEventIds,
tableId,
isSelectAllChecked,
sort,
browserFields,
onSelectPage,
theme,
setEventsLoading,
setEventsDeleted,
itemsPerPage,
]);
const alertBulkActions = useAlertBulkActions({
tableId,
data: nonDeletedEvents,
totalItems: totalCountMinusDeleted,
refetch,
indexNames: selectedPatterns,
hasAlertsCrud: hasCrudPermissions,
showCheckboxes,
filterStatus: currentFilter,
filterQuery,
bulkActions,
selectedCount,
});
// Store context in state rather than creating object in provider value={} to prevent re-renders caused by a new object being created
const [activeStatefulEventContext] = useState({
timelineID: tableId,
tabType: 'query',
enableHostDetailsFlyout: true,
enableIpDetailsFlyout: true,
});
const unitCountText = useMemo(
() => `${totalCountMinusDeleted.toLocaleString()} ${unit(totalCountMinusDeleted)}`,
[totalCountMinusDeleted, unit]
);
return (
<>
<FullScreenContainer $isFullScreen={globalFullScreen}>
<InspectButtonContainer>
{timelinesUi.getTGrid<'embedded'>({
additionalFilters,
appId: APP_UI_ID,
browserFields,
bulkActions,
columns,
dataViewId,
defaultCellActions,
deletedEventIds,
disabledCellActions: FIELDS_WITHOUT_CELL_ACTIONS,
end,
entityType,
fieldBrowserOptions,
filters: globalFilters,
filterStatus: currentFilter,
getRowRenderer,
globalFullScreen,
graphEventId,
graphOverlay,
id: tableId,
indexNames: selectedPatterns,
indexPattern,
isLive,
isLoadingIndexPattern,
itemsPerPage,
itemsPerPageOptions,
leadingControlColumns,
onRuleChange,
query,
renderCellValue,
rowRenderers,
runtimeMappings,
setQuery,
sort,
start,
tGridEventRenderedViewEnabled,
trailingControlColumns,
type: 'embedded',
unit,
})}
<StyledEuiPanel
hasBorder={false}
hasShadow={false}
paddingSize="none"
data-test-subj="events-viewer-panel"
$isFullScreen={globalFullScreen}
>
{showFullLoading && <TableLoading height="short" />}
{graphOverlay}
{canQueryTimeline && (
<TableContext.Provider value={tableContext}>
<EventsContainerLoading
data-timeline-id={tableId}
data-test-subj={`events-container-loading-${loading}`}
>
<RightTopMenu
tableView={tableView}
loading={loading}
tableId={tableId}
title={title}
onViewChange={(selectedView) => setTableView(selectedView)}
additionalFilters={additionalFilters}
hasRightOffset={tableView === 'gridView' && nonDeletedEvents.length > 0}
/>
{!hasAlerts && !loading && !graphOverlay && <EmptyTable height="short" />}
{hasAlerts && (
<FullWidthFlexGroupTable
$visible={!graphEventId && graphOverlay == null}
gutterSize="none"
>
<ScrollableFlexItem grow={1}>
<StatefulEventContext.Provider value={activeStatefulEventContext}>
{tableView === 'gridView' && (
<DataTableComponent
additionalControls={alertBulkActions}
unitCountText={unitCountText}
browserFields={browserFields}
data={nonDeletedEvents}
disabledCellActions={FIELDS_WITHOUT_CELL_ACTIONS}
id={tableId}
loadPage={loadPage}
renderCellValue={renderCellValue}
rowRenderers={rowRenderers}
totalItems={totalCountMinusDeleted}
bulkActions={bulkActions}
fieldBrowserOptions={fieldBrowserOptions}
defaultCellActions={defaultCellActions}
hasCrudPermissions={hasCrudPermissions}
filters={filters}
leadingControlColumns={transformedLeadingControlColumns}
pagination={{
pageIndex: pageInfo.activePage,
pageSize: itemsPerPage,
pageSizeOptions: itemsPerPageOptions,
onChangeItemsPerPage,
onChangePage,
}}
/>
)}
{tableView === 'eventRenderedView' && (
<EventRenderedView
events={nonDeletedEvents}
getRowRenderer={getRowRenderer}
leadingControlColumns={transformedLeadingControlColumns}
pagination={{
pageIndex: pageInfo.activePage,
pageSize: itemsPerPage,
totalItemCount: totalCountMinusDeleted,
pageSizeOptions: itemsPerPageOptions,
showPerPageOptions: true,
}}
rowRenderers={rowRenderers}
scopeId={tableId}
onChangePage={onChangePage}
onChangeItemsPerPage={onChangeItemsPerPage}
additionalControls={alertBulkActions}
unitCountText={unitCountText}
/>
)}
</StatefulEventContext.Provider>
</ScrollableFlexItem>
</FullWidthFlexGroupTable>
)}
</EventsContainerLoading>
</TableContext.Provider>
)}
</StyledEuiPanel>
</InspectButtonContainer>
</FullScreenContainer>
{DetailsPanel}
@ -244,4 +603,15 @@ const StatefulEventsViewerComponent: React.FC<Props> = ({
);
};
export const StatefulEventsViewer = React.memo(StatefulEventsViewerComponent);
const mapDispatchToProps = {
clearSelected: dataTableActions.clearSelected,
setSelected: dataTableActions.setSelected,
};
const connector = connect(undefined, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
export const StatefulEventsViewer: React.FunctionComponent<EventsViewerProps> = connector(
StatefulEventsViewerComponent
);

View file

@ -12,4 +12,10 @@ export const mockEventViewerResponse = {
fakeTotalCount: 100,
},
events: [],
inspect: {
dsl: [],
response: [],
},
loadPage: jest.fn(),
refetch: jest.fn(),
};

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React, { useMemo } from 'react';
import styled from 'styled-components';
import { TableId } from '../../../../common/types';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { InspectButton } from '../inspect';
import { UpdatedFlexGroup, UpdatedFlexItem } from './styles';
import type { ViewSelection } from './summary_view_select';
import { SummaryViewSelector } from './summary_view_select';
const TitleText = styled.span`
margin-right: 12px;
`;
interface Props {
tableView: ViewSelection;
loading: boolean;
tableId: TableId;
title: string;
onViewChange: (viewSelection: ViewSelection) => void;
additionalFilters?: React.ReactNode;
hasRightOffset?: boolean;
}
export const RightTopMenu = ({
tableView,
loading,
tableId,
title,
onViewChange,
additionalFilters,
hasRightOffset,
}: Props) => {
const alignItems = tableView === 'gridView' ? 'baseline' : 'center';
const justTitle = useMemo(() => <TitleText data-test-subj="title">{title}</TitleText>, [title]);
const tGridEventRenderedViewEnabled = useIsExperimentalFeatureEnabled(
'tGridEventRenderedViewEnabled'
);
return (
<UpdatedFlexGroup
alignItems={alignItems}
data-test-subj="events-viewer-updated"
gutterSize="m"
justifyContent="flexEnd"
$hasRightOffset={hasRightOffset}
>
<UpdatedFlexItem grow={false} $show={!loading}>
<InspectButton title={justTitle} queryId={tableId} />
</UpdatedFlexItem>
<UpdatedFlexItem grow={false} $show={!loading}>
{additionalFilters}
</UpdatedFlexItem>
{tGridEventRenderedViewEnabled &&
[TableId.alertsOnRuleDetailsPage, TableId.alertsOnAlertsPage].includes(tableId) && (
<UpdatedFlexItem grow={false} $show={!loading}>
<SummaryViewSelector viewSelected={tableView} onViewChange={onViewChange} />
</UpdatedFlexItem>
)}
</UpdatedFlexGroup>
);
};

View file

@ -26,7 +26,7 @@ const heights = {
export const TableContext = createContext<{ tableId: string | null }>({ tableId: null });
export const TGridLoading: React.FC<{ height?: keyof typeof heights }> = ({ height = 'tall' }) => {
export const TableLoading: React.FC<{ height?: keyof typeof heights }> = ({ height = 'tall' }) => {
return (
<EuiPanel color="subdued">
<EuiFlexGroup
@ -47,7 +47,7 @@ const panelStyle = {
maxWidth: 500,
};
export const TGridEmpty: React.FC<{ height?: keyof typeof heights }> = ({ height = 'tall' }) => {
export const EmptyTable: React.FC<{ height?: keyof typeof heights }> = ({ height = 'tall' }) => {
const { http } = useKibana<CoreStart>().services;
return (
@ -61,14 +61,14 @@ export const TGridEmpty: React.FC<{ height?: keyof typeof heights }> = ({ height
<EuiTitle>
<h3>
<FormattedMessage
id="xpack.timelines.tgrid.empty.title"
id="xpack.securitySolution.eventsViewer.empty.title"
defaultMessage="No results match your search criteria"
/>
</h3>
</EuiTitle>
<p>
<FormattedMessage
id="xpack.timelines.tgrid.empty.description"
id="xpack.securitySolution.eventsViewer.empty.description"
defaultMessage="Try searching over a longer period of time or modifying your search"
/>
</p>

View file

@ -6,6 +6,11 @@
*/
import { createContext } from 'react';
import { StatefulEventContextType } from '../types';
export interface StatefulEventContextType {
tabType: string | undefined;
timelineID: string;
enableHostDetailsFlyout: boolean;
enableIpDetailsFlyout: boolean;
}
export const StatefulEventContext = createContext<StatefulEventContextType | null>(null);

View file

@ -0,0 +1,79 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
import styled from 'styled-components';
export const SELECTOR_TIMELINE_GLOBAL_CONTAINER = 'securitySolutionTimeline__container';
export const EVENTS_TABLE_CLASS_NAME = 'siemEventsTable';
export const FullScreenContainer = styled.div<{ $isFullScreen: boolean }>`
height: ${({ $isFullScreen }) => ($isFullScreen ? '100%' : undefined)};
flex: 1 1 auto;
display: flex;
width: 100%;
`;
export const FullWidthFlexGroupTable = styled(EuiFlexGroup)<{ $visible: boolean }>`
overflow: hidden;
margin: 0;
display: ${({ $visible }) => ($visible ? 'flex' : 'none')};
`;
export const ScrollableFlexItem = styled(EuiFlexItem)`
overflow: auto;
`;
export const FullWidthFlexGroup = styled(EuiFlexGroup)<{ $visible?: boolean }>`
overflow: hidden;
margin: 0;
min-height: 490px;
display: ${({ $visible = true }) => ($visible ? 'flex' : 'none')};
`;
export const UpdatedFlexGroup = styled(EuiFlexGroup)<{
$hasRightOffset?: boolean;
}>`
${({ $hasRightOffset, theme }) =>
$hasRightOffset
? `margin-right: ${theme.eui.euiSizeXL};`
: `margin-right: ${theme.eui.euiSizeXS};`}
position: absolute;
z-index: ${({ theme }) => theme.eui.euiZLevel1 - 3};
${({ $hasRightOffset, theme }) =>
$hasRightOffset ? `right: ${theme.eui.euiSizeXL};` : `right: ${theme.eui.euiSizeXS};`}
`;
export const UpdatedFlexItem = styled(EuiFlexItem)<{ $show: boolean }>`
${({ $show }) => ($show ? '' : 'visibility: hidden;')}
`;
export const EventsContainerLoading = styled.div.attrs(({ className = '' }) => ({
className: `${SELECTOR_TIMELINE_GLOBAL_CONTAINER} ${className}`,
}))`
position: relative;
width: 100%;
overflow: hidden;
flex: 1;
display: flex;
flex-direction: column;
`;
export const StyledEuiPanel = styled(EuiPanel)<{ $isFullScreen: boolean }>`
display: flex;
flex-direction: column;
position: relative;
width: 100%;
${({ $isFullScreen }) =>
$isFullScreen &&
`
border: 0;
box-shadow: none;
padding-top: 0;
padding-bottom: 0;
`}
`;

View file

@ -5,20 +5,13 @@
* 2.0.
*/
import {
EuiButtonEmpty,
EuiPopover,
EuiSelectable,
EuiSelectableOption,
EuiTitle,
EuiTextColor,
} from '@elastic/eui';
import type { EuiSelectableOption } from '@elastic/eui';
import { EuiButtonEmpty, EuiPopover, EuiSelectable, EuiTitle, EuiTextColor } from '@elastic/eui';
import { Storage } from '@kbn/kibana-utils-plugin/public';
import { i18n } from '@kbn/i18n';
import React, { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import { ALERTS_TABLE_VIEW_SELECTION_KEY } from '../../helpers';
import { ALERTS_TABLE_VIEW_SELECTION_KEY } from '../../event_rendered_view/helpers';
const storage = new Storage(localStorage);
@ -32,12 +25,12 @@ const ContainerEuiSelectable = styled.div`
}
`;
const gridView = i18n.translate('xpack.timelines.alerts.summaryView.gridView.label', {
const gridView = i18n.translate('xpack.securitySolution.selector.summaryView.gridView.label', {
defaultMessage: 'Grid view',
});
const eventRenderedView = i18n.translate(
'xpack.timelines.alerts.summaryView.eventRendererView.label',
'xpack.securitySolution.selector.summaryView.eventRendererView.label',
{
defaultMessage: 'Event rendered view',
}
@ -91,10 +84,13 @@ const SummaryViewSelectorComponent = ({ viewSelected, onViewChange }: SummaryVie
checked: (viewSelected === 'gridView' ? 'on' : undefined) as EuiSelectableOption['checked'],
meta: [
{
text: i18n.translate('xpack.timelines.alerts.summaryView.options.default.description', {
defaultMessage:
'View as tabular data with the ability to group and sort by specific fields',
}),
text: i18n.translate(
'xpack.securitySolution.selector.summaryView.options.default.description',
{
defaultMessage:
'View as tabular data with the ability to group and sort by specific fields',
}
),
},
],
},
@ -107,7 +103,7 @@ const SummaryViewSelectorComponent = ({ viewSelected, onViewChange }: SummaryVie
meta: [
{
text: i18n.translate(
'xpack.timelines.alerts.summaryView.options.summaryView.description',
'xpack.securitySolution.selector.summaryView.options.summaryView.description',
{
defaultMessage: 'View a rendering of the event flow for each alert',
}

View file

@ -24,3 +24,10 @@ export const UNIT = (totalCount: number) =>
export const ACTIONS = i18n.translate('xpack.securitySolution.eventsViewer.actionsColumnLabel', {
defaultMessage: 'Actions',
});
export const ERROR_TIMELINE_EVENTS = i18n.translate(
'xpack.securitySolution.eventsViewer.timelineEvents.errorSearchDescription',
{
defaultMessage: `An error has occurred on timeline events search`,
}
);

View file

@ -0,0 +1,130 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { EuiLoadingSpinner } from '@elastic/eui';
import React, { lazy, Suspense, useMemo } from 'react';
import type { TimelineItem } from '../../../../common/search_strategy';
import type { AlertWorkflowStatus, Refetch } from '../../types';
import type { BulkActionsProp } from '../toolbar/bulk_actions/types';
const StatefulAlertBulkActions = lazy(() => import('../toolbar/bulk_actions/alert_bulk_actions'));
interface OwnProps {
tableId: string;
data: TimelineItem[];
totalItems: number;
refetch: Refetch;
indexNames: string[];
hasAlertsCrud: boolean;
showCheckboxes: boolean;
filterStatus?: AlertWorkflowStatus;
filterQuery?: string;
bulkActions?: BulkActionsProp;
selectedCount?: number;
}
export const useAlertBulkActions = ({
tableId,
data,
totalItems,
refetch,
indexNames,
hasAlertsCrud,
showCheckboxes,
filterStatus,
filterQuery,
bulkActions,
selectedCount,
}: OwnProps) => {
const showBulkActions = useMemo(() => {
if (!hasAlertsCrud) {
return false;
}
if (selectedCount === 0 || !showCheckboxes) {
return false;
}
if (typeof bulkActions === 'boolean') {
return bulkActions;
}
return (bulkActions?.customBulkActions?.length || bulkActions?.alertStatusActions) ?? true;
}, [hasAlertsCrud, selectedCount, showCheckboxes, bulkActions]);
const onAlertStatusActionSuccess = useMemo(() => {
if (bulkActions && bulkActions !== true) {
return bulkActions.onAlertStatusActionSuccess;
}
}, [bulkActions]);
const onAlertStatusActionFailure = useMemo(() => {
if (bulkActions && bulkActions !== true) {
return bulkActions.onAlertStatusActionFailure;
}
}, [bulkActions]);
const showAlertStatusActions = useMemo(() => {
if (!hasAlertsCrud) {
return false;
}
if (typeof bulkActions === 'boolean') {
return bulkActions;
}
return (bulkActions && bulkActions.alertStatusActions) ?? true;
}, [bulkActions, hasAlertsCrud]);
const additionalBulkActions = useMemo(() => {
if (bulkActions && bulkActions !== true && bulkActions.customBulkActions !== undefined) {
return bulkActions.customBulkActions.map((action) => {
return {
...action,
onClick: (eventIds: string[]) => {
const items = data.filter((item) => {
return eventIds.find((event) => item._id === event);
});
action.onClick(items);
},
};
});
}
}, [bulkActions, data]);
const alertBulkActions = useMemo(
() => (
<>
{showBulkActions && (
<Suspense fallback={<EuiLoadingSpinner />}>
<StatefulAlertBulkActions
showAlertStatusActions={showAlertStatusActions}
data-test-subj="bulk-actions"
id={tableId}
totalItems={totalItems}
filterStatus={filterStatus}
query={filterQuery}
indexName={indexNames.join()}
onActionSuccess={onAlertStatusActionSuccess}
onActionFailure={onAlertStatusActionFailure}
customBulkActions={additionalBulkActions}
refetch={refetch}
/>
</Suspense>
)}
</>
),
[
additionalBulkActions,
filterQuery,
filterStatus,
indexNames,
onAlertStatusActionFailure,
onAlertStatusActionSuccess,
refetch,
showAlertStatusActions,
showBulkActions,
tableId,
totalItems,
]
);
return alertBulkActions;
};

View file

@ -10,40 +10,33 @@ import { isEmpty, isString, noop } from 'lodash/fp';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Subscription } from 'rxjs';
import { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { DataView } from '@kbn/data-views-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { isCompleteResponse, isErrorResponse } from '@kbn/data-plugin/common';
import {
clearEventsLoading,
clearEventsDeleted,
setTableUpdatedAt,
updateGraphEventId,
updateTotalCount,
} from '../store/t_grid/actions';
import {
Direction,
TimelineFactoryQueryTypes,
TimelineEventsQueries,
EntityType,
} from '../../common/search_strategy';
import type {
Inspect,
PaginationInputPaginated,
TimelineStrategyResponseType,
TimelineEdges,
TimelineEventsAllRequestOptions,
TimelineEventsAllStrategyResponse,
TimelineItem,
} from '@kbn/timelines-plugin/common';
import type {
EntityType,
TimelineFactoryQueryTypes,
TimelineRequestSortField,
} from '../../common/search_strategy';
import type { ESQuery } from '../../common/typed_json';
import type { KueryFilterQueryKind, AlertStatus } from '../../common/types/timeline';
import { useAppToasts } from '../hooks/use_app_toasts';
import { TableId } from '../store/t_grid/types';
import * as i18n from './translations';
import { getSearchTransactionName, useStartTransaction } from '../lib/apm/use_start_transaction';
TimelineStrategyResponseType,
} from '@kbn/timelines-plugin/common/search_strategy';
import type { MappingRuntimeFields } from '@elastic/elasticsearch/lib/api/types';
import { TimelineEventsQueries } from '../../../../common/search_strategy';
import type { KueryFilterQueryKind } from '../../../../common/types';
import { Direction, TableId } from '../../../../common/types';
import type { ESQuery } from '../../../../common/typed_json';
import { useAppToasts } from '../../hooks/use_app_toasts';
import { dataTableActions } from '../../store/data_table';
import { ERROR_TIMELINE_EVENTS } from './translations';
import type { AlertWorkflowStatus } from '../../types';
import { getSearchTransactionName, useStartTransaction } from '../../lib/apm/use_start_transaction';
export type InspectResponse = Inspect & { response: string[] };
export const detectionsTimelineIds = [TableId.alertsOnAlertsPage, TableId.alertsOnRuleDetailsPage];
@ -90,7 +83,7 @@ export interface UseTimelineEventsProps {
sort?: TimelineRequestSortField[];
startDate: string;
timerangeKind?: 'absolute' | 'relative';
filterStatus?: AlertStatus;
filterStatus?: AlertWorkflowStatus;
}
const createFilter = (filterQuery: ESQuery | string | undefined) =>
@ -108,7 +101,7 @@ const getInspectResponse = <T extends TimelineFactoryQueryTypes>(
response != null ? [JSON.stringify(response.rawResponse, null, 2)] : prevResponse?.response,
});
const ID = 'timelineEventsQuery';
const ID = 'eventsQuery';
export const initSortDefault = [
{
direction: Direction.desc,
@ -118,7 +111,7 @@ export const initSortDefault = [
},
];
const useApmTracking = (timelineId: string) => {
const useApmTracking = (tableId: string) => {
const { startTransaction } = useStartTransaction();
const startTracking = useCallback(() => {
@ -126,7 +119,7 @@ const useApmTracking = (timelineId: string) => {
// The managed flag can be turned on to investigate high latency requests in APM.
// However, note that by enabling the managed flag, the transaction trace may be distorted by other requests information.
const transaction = startTransaction({
name: getSearchTransactionName(timelineId),
name: getSearchTransactionName(tableId),
type: 'http-request',
options: { managed: false },
});
@ -139,7 +132,7 @@ const useApmTracking = (timelineId: string) => {
span?.end();
},
};
}, [startTransaction, timelineId]);
}, [startTransaction, tableId]);
return { startTracking };
};
@ -179,8 +172,8 @@ export const useTimelineEventsHandler = ({
const clearSignalsState = useCallback(() => {
if (id != null && detectionsTimelineIds.some((timelineId) => timelineId === id)) {
dispatch(clearEventsLoading({ id }));
dispatch(clearEventsDeleted({ id }));
dispatch(dataTableActions.clearEventsLoading({ id }));
dispatch(dataTableActions.clearEventsDeleted({ id }));
}
}, [dispatch, id]);
@ -201,13 +194,13 @@ export const useTimelineEventsHandler = ({
const setUpdated = useCallback(
(updatedAt: number) => {
dispatch(setTableUpdatedAt({ id, updated: updatedAt }));
dispatch(dataTableActions.setTableUpdatedAt({ id, updated: updatedAt }));
},
[dispatch, id]
);
const setTotalCount = useCallback(
(totalCount: number) => dispatch(updateTotalCount({ id, totalCount })),
(totalCount: number) => dispatch(dataTableActions.updateTotalCount({ id, totalCount })),
[dispatch, id]
);
@ -243,7 +236,6 @@ export const useTimelineEventsHandler = ({
if (data && data.search) {
const { endTracking } = startTracking();
const abortSignal = abortCtrl.current.signal;
searchSubscription$.current = data.search
.search<TimelineRequest<typeof language>, TimelineResponse<typeof language>>(
{ ...request, entityType },
@ -260,7 +252,6 @@ export const useTimelineEventsHandler = ({
.subscribe({
next: (response) => {
if (isCompleteResponse(response)) {
endTracking('success');
setTimelineResponse((prevResponse) => {
const newTimelineResponse = {
...prevResponse,
@ -277,21 +268,20 @@ export const useTimelineEventsHandler = ({
return newTimelineResponse;
});
if (prevFilterStatus !== request.filterStatus) {
dispatch(updateGraphEventId({ id, graphEventId: '' }));
dispatch(dataTableActions.updateGraphEventId({ id, graphEventId: '' }));
}
setFilterStatus(request.filterStatus);
setLoading(false);
searchSubscription$.current.unsubscribe();
} else if (isErrorResponse(response)) {
endTracking('invalid');
setLoading(false);
addWarning(i18n.ERROR_TIMELINE_EVENTS);
endTracking('invalid');
addWarning(ERROR_TIMELINE_EVENTS);
searchSubscription$.current.unsubscribe();
}
},
error: (msg) => {
endTracking(abortSignal.aborted ? 'aborted' : 'error');
setLoading(false);
data.search.showError(msg);
searchSubscription$.current.unsubscribe();

View file

@ -13,7 +13,6 @@ import { useTourContext } from './tour';
import { mockGlobalState, SUB_PLUGINS_REDUCER, TestProviders } from '../../mock';
import { TimelineId } from '../../../../common/types';
import { createStore } from '../../store';
import { tGridReducer } from '@kbn/timelines-plugin/public';
import { kibanaObservable } from '@kbn/timelines-plugin/public/mock';
import { createSecuritySolutionStorageMock } from '@kbn/timelines-plugin/public/mock/mock_local_storage';
@ -274,13 +273,7 @@ describe('SecurityTourStep', () => {
},
};
const { storage } = createSecuritySolutionStorageMock();
const mockStore = createStore(
mockstate,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const mockStore = createStore(mockstate, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
render(
<TestProviders store={mockStore}>

View file

@ -8,9 +8,8 @@
import type { MouseEvent } from 'react';
import React from 'react';
import { EuiContextMenuItem, EuiButtonIcon, EuiToolTip, EuiText } from '@elastic/eui';
import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public';
import { EventsTdContent } from '../../styles';
import { EventsTdContent } from '../../../timelines/components/timeline/styles';
import { DEFAULT_ACTION_BUTTON_WIDTH } from '.';
interface ActionIconItemProps {
ariaLabel?: string;

View file

@ -7,33 +7,30 @@
import { mount } from 'enzyme';
import React from 'react';
import { TableId, TimelineId } from '../../../../../../common/types/timeline';
import { TestProviders, mockTimelineModel, mockTimelineData } from '../../../../../common/mock';
import { Actions, isAlert } from '.';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { mockCasesContract } from '@kbn/cases-plugin/public/mocks';
import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector';
import { licenseService } from '../../../../../common/hooks/use_license';
import { useTourContext } from '../../../../../common/components/guided_onboarding_tour';
import {
GuidedOnboardingTourStep,
SecurityTourStep,
} from '../../../../../common/components/guided_onboarding_tour/tour_step';
import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '../../../../../common/components/user_privileges/user_privileges_context';
import { useUserPrivileges } from '../../../../../common/components/user_privileges';
import { SecurityStepId } from '../../../../../common/components/guided_onboarding_tour/tour_config';
import { mockTimelineData, mockTimelineModel, TestProviders } from '../../mock';
import { useShallowEqualSelector } from '../../hooks/use_selector';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { licenseService } from '../../hooks/use_license';
import { TableId } from '../../../../common/types';
import { useTourContext } from '../guided_onboarding_tour';
import { GuidedOnboardingTourStep, SecurityTourStep } from '../guided_onboarding_tour/tour_step';
import { SecurityStepId } from '../guided_onboarding_tour/tour_config';
import { Actions } from './actions';
import { initialUserPrivilegesState as mockInitialUserPrivilegesState } from '../user_privileges/user_privileges_context';
import { useUserPrivileges } from '../user_privileges';
jest.mock('../../../../../common/components/user_privileges');
jest.mock('../../../../../common/components/guided_onboarding_tour');
jest.mock('../../../../../detections/components/user_info', () => ({
jest.mock('../guided_onboarding_tour');
jest.mock('../user_privileges');
jest.mock('../../../detections/components/user_info', () => ({
useUserData: jest.fn().mockReturnValue([{ canUserCRUD: true, hasIndexWrite: true }]),
}));
jest.mock('../../../../../common/hooks/use_experimental_features', () => ({
jest.mock('../../hooks/use_experimental_features', () => ({
useIsExperimentalFeatureEnabled: jest.fn().mockReturnValue(false),
}));
jest.mock('../../../../../common/hooks/use_selector');
jest.mock('../../hooks/use_selector');
jest.mock(
'../../../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline',
'../../../detections/components/alerts_table/timeline_actions/use_investigate_in_timeline',
() => ({
useInvestigateInTimeline: jest.fn().mockReturnValue({
investigateInTimelineActionItems: [],
@ -43,8 +40,8 @@ jest.mock(
})
);
jest.mock('../../../../../common/lib/kibana', () => {
const originalKibanaLib = jest.requireActual('../../../../../common/lib/kibana');
jest.mock('../../lib/kibana', () => {
const originalKibanaLib = jest.requireActual('../../lib/kibana');
return {
useKibana: () => ({
@ -69,6 +66,7 @@ jest.mock('../../../../../common/lib/kibana', () => {
addError: jest.fn(),
addSuccess: jest.fn(),
addWarning: jest.fn(),
remove: jest.fn(),
}),
useNavigateTo: jest.fn().mockReturnValue({
navigateTo: jest.fn(),
@ -77,7 +75,7 @@ jest.mock('../../../../../common/lib/kibana', () => {
};
});
jest.mock('../../../../../common/hooks/use_license', () => {
jest.mock('../../hooks/use_license', () => {
const licenseServiceInstance = {
isPlatinumPlus: jest.fn(),
isEnterprise: jest.fn(() => false),
@ -110,7 +108,7 @@ const defaultProps = {
setEventsLoading: () => {},
showCheckboxes: true,
showNotes: false,
timelineId: TimelineId.test,
timelineId: 'test',
toggleShowNotes: () => {},
};
@ -410,7 +408,7 @@ describe('Actions', () => {
<Actions
{...defaultProps}
ecsData={ecsData}
timelineId={TableId.kubernetesPageSessions} // not a bug, this needs to be fixed by providing a generic interface for actions registry
timelineId={TableId.kubernetesPageSessions}
/>
</TestProviders>
);
@ -439,14 +437,4 @@ describe('Actions', () => {
expect(wrapper.find('[data-test-subj="session-view-button"]').exists()).toEqual(true);
});
});
describe('isAlert', () => {
test('it returns true when the eventType is an alert', () => {
expect(isAlert('signal')).toBe(true);
});
test('it returns false when the eventType is NOT an alert', () => {
expect(isAlert('raw')).toBe(false);
});
});
});

View file

@ -10,44 +10,35 @@ import { useDispatch } from 'react-redux';
import { EuiButtonIcon, EuiCheckbox, EuiLoadingSpinner, EuiToolTip } from '@elastic/eui';
import styled from 'styled-components';
import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public';
import { GuidedOnboardingTourStep } from '../../../../../common/components/guided_onboarding_tour/tour_step';
import { isDetectionsAlertsTable } from '../../../../../common/components/top_n/helpers';
import { useTourContext } from '../../../../../common/components/guided_onboarding_tour';
import {
AlertsCasesTourSteps,
SecurityStepId,
} from '../../../../../common/components/guided_onboarding_tour/tour_config';
import { getScopedActions, isTimelineScope } from '../../../../../helpers';
import { useIsExperimentalFeatureEnabled } from '../../../../../common/hooks/use_experimental_features';
import { eventHasNotes, getEventType, getPinOnClick } from '../helpers';
import { AlertContextMenu } from '../../../../../detections/components/alerts_table/timeline_actions/alert_context_menu';
import { InvestigateInTimelineAction } from '../../../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action';
eventHasNotes,
getEventType,
getPinOnClick,
} from '../../../timelines/components/timeline/body/helpers';
import { getScopedActions, isTimelineScope } from '../../../helpers';
import { isInvestigateInResolverActionEnabled } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline';
import type { ActionProps, OnPinEvent } from '../../../../common/types';
import { TableId, TimelineId, TimelineTabs } from '../../../../common/types';
import { AddEventNoteAction } from './add_note_icon_item';
import { PinEventAction } from './pin_event_action';
import { EventsTdContent } from '../../styles';
import * as i18n from '../translations';
import { useShallowEqualSelector } from '../../../../../common/hooks/use_selector';
import { setActiveTabTimeline } from '../../../../store/timeline/actions';
import {
useGlobalFullScreen,
useTimelineFullScreen,
} from '../../../../../common/containers/use_full_screen';
import type {
ActionProps,
OnPinEvent,
TimelineEventsType,
} from '../../../../../../common/types/timeline';
import { TableId, TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
import { timelineActions, timelineSelectors } from '../../../../store/timeline';
import { timelineDefaults } from '../../../../store/timeline/defaults';
import { isInvestigateInResolverActionEnabled } from '../../../../../detections/components/alerts_table/timeline_actions/investigate_in_resolver';
import { useStartTransaction } from '../../../../../common/lib/apm/use_start_transaction';
import { ALERTS_ACTIONS } from '../../../../../common/lib/apm/user_actions';
import { useLicense } from '../../../../../common/hooks/use_license';
export const isAlert = (eventType: TimelineEventsType | Omit<TimelineEventsType, 'all'>): boolean =>
eventType === 'signal';
import { useShallowEqualSelector } from '../../hooks/use_selector';
import { timelineDefaults } from '../../../timelines/store/timeline/defaults';
import { useStartTransaction } from '../../lib/apm/use_start_transaction';
import { useLicense } from '../../hooks/use_license';
import { useGlobalFullScreen, useTimelineFullScreen } from '../../containers/use_full_screen';
import { ALERTS_ACTIONS } from '../../lib/apm/user_actions';
import { setActiveTabTimeline } from '../../../timelines/store/timeline/actions';
import { EventsTdContent } from '../../../timelines/components/timeline/styles';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { AlertContextMenu } from '../../../detections/components/alerts_table/timeline_actions/alert_context_menu';
import { InvestigateInTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/investigate_in_timeline_action';
import * as i18n from './translations';
import { useTourContext } from '../guided_onboarding_tour';
import { AlertsCasesTourSteps, SecurityStepId } from '../guided_onboarding_tour/tour_config';
import { isDetectionsAlertsTable } from '../top_n/helpers';
import { GuidedOnboardingTourStep } from '../guided_onboarding_tour/tour_step';
import { DEFAULT_ACTION_BUTTON_WIDTH, isAlert } from './helpers';
const ActionsContainer = styled.div`
align-items: center;

View file

@ -5,16 +5,16 @@
* 2.0.
*/
import { TimelineType } from '../../../../common/types';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { TestProviders } from '../../mock';
import { useUserPrivileges } from '../user_privileges';
import { getEndpointPrivilegesInitialStateMock } from '../user_privileges/endpoint/mocks';
import { AddEventNoteAction } from './add_note_icon_item';
import { useUserPrivileges } from '../../../../../common/components/user_privileges';
import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/components/user_privileges/endpoint/mocks';
import { TestProviders } from '../../../../../common/mock';
import { TimelineType } from '../../../../../../common/types';
jest.mock('../../../../../common/components/user_privileges');
jest.mock('../user_privileges');
const useUserPrivilegesMock = useUserPrivileges as jest.Mock;
describe('AddEventNoteAction', () => {

View file

@ -6,12 +6,11 @@
*/
import React from 'react';
import { TimelineType } from '../../../../../../common/types/timeline';
import * as i18n from '../translations';
import { NotesButton } from '../../properties/helpers';
import { NotesButton } from '../../../timelines/components/timeline/properties/helpers';
import { TimelineType } from '../../../../common/types';
import { useUserPrivileges } from '../user_privileges';
import * as i18n from './translations';
import { ActionIconItem } from './action_icon_item';
import { useUserPrivileges } from '../../../../../common/components/user_privileges';
interface AddEventNoteActionProps {
ariaLabel?: string;

View file

@ -7,19 +7,15 @@
import React from 'react';
import { render } from '@testing-library/react';
import { TestProviders, mockTimelineModel } from '../../../../../common/mock';
import { mockTimelineModel, TestProviders } from '../../mock';
import { mockTriggersActionsUi } from '../../mock/mock_triggers_actions_ui_plugin';
import type { ColumnHeaderOptions, HeaderActionProps } from '../../../../common/types';
import { TimelineTabs } from '../../../../common/types';
import { HeaderActions } from './header_actions';
import { mockTriggersActionsUi } from '../../../../../common/mock/mock_triggers_actions_ui_plugin';
import type {
ColumnHeaderOptions,
HeaderActionProps,
} from '../../../../../../common/types/timeline';
import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
import { timelineActions } from '../../../../store/timeline';
import { getColumnHeader } from '../column_headers/helpers';
import { timelineActions } from '../../../timelines/store/timeline';
import { getColumnHeader } from '../../../timelines/components/timeline/body/column_headers/helpers';
jest.mock('../../../row_renderers_browser', () => ({
jest.mock('../../../timelines/components/row_renderers_browser', () => ({
StatefulRowRenderersBrowser: () => null,
}));
@ -29,13 +25,13 @@ jest.mock('react-redux', () => ({
useDispatch: () => mockDispatch,
}));
jest.mock('../../../../../common/hooks/use_selector', () => ({
jest.mock('../../hooks/use_selector', () => ({
useDeepEqualSelector: () => mockTimelineModel,
useShallowEqualSelector: jest.fn(),
}));
const columnId = 'test-field';
const timelineId = TimelineId.test;
const timelineId = 'test-timeline';
/* eslint-disable jsx-a11y/click-events-have-key-events */
mockTriggersActionsUi.getFieldBrowser.mockImplementation(
@ -53,7 +49,7 @@ mockTriggersActionsUi.getFieldBrowser.mockImplementation(
)
);
jest.mock('../../../../../common/lib/kibana', () => ({
jest.mock('../../lib/kibana', () => ({
useKibana: () => ({
services: {
triggersActionsUi: { ...mockTriggersActionsUi },

View file

@ -11,25 +11,22 @@ import { EuiButtonIcon, EuiCheckbox, EuiToolTip, useDataGridColumnSorting } from
import { useDispatch } from 'react-redux';
import styled from 'styled-components';
import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public';
import { isActiveTimeline } from '../../../../../helpers';
import type { HeaderActionProps, SortDirection } from '../../../../../../common/types/timeline';
import { TimelineId, TimelineTabs } from '../../../../../../common/types/timeline';
import { EXIT_FULL_SCREEN } from '../../../../../common/components/exit_full_screen/translations';
import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../../../common/constants';
import {
useGlobalFullScreen,
useTimelineFullScreen,
} from '../../../../../common/containers/use_full_screen';
import { useDeepEqualSelector } from '../../../../../common/hooks/use_selector';
import { StatefulRowRenderersBrowser } from '../../../row_renderers_browser';
import { EventsTh, EventsThContent } from '../../styles';
import { EventsSelect } from '../column_headers/events_select';
import * as i18n from '../column_headers/translations';
import { timelineActions, timelineSelectors } from '../../../../store/timeline';
import { isFullScreen } from '../column_headers';
import { useKibana } from '../../../../../common/lib/kibana';
import { getColumnHeader } from '../column_headers/helpers';
import type { HeaderActionProps, SortDirection } from '../../../../common/types';
import { TimelineTabs, TimelineId } from '../../../../common/types';
import { isFullScreen } from '../../../timelines/components/timeline/body/column_headers';
import { isActiveTimeline } from '../../../helpers';
import { getColumnHeader } from '../../../timelines/components/timeline/body/column_headers/helpers';
import { timelineActions, timelineSelectors } from '../../../timelines/store/timeline';
import { useDeepEqualSelector } from '../../hooks/use_selector';
import { useGlobalFullScreen, useTimelineFullScreen } from '../../containers/use_full_screen';
import { useKibana } from '../../lib/kibana';
import { DEFAULT_ACTION_BUTTON_WIDTH } from '.';
import { EventsTh, EventsThContent } from '../../../timelines/components/timeline/styles';
import { StatefulRowRenderersBrowser } from '../../../timelines/components/row_renderers_browser';
import { EXIT_FULL_SCREEN } from '../exit_full_screen/translations';
import { FULL_SCREEN_TOGGLED_CLASS_NAME } from '../../../../common/constants';
import { EventsSelect } from '../../../timelines/components/timeline/body/column_headers/events_select';
import * as i18n from './translations';
const SortingColumnsContainer = styled.div`
button {

View file

@ -0,0 +1,50 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { euiThemeVars } from '@kbn/ui-theme';
import { DEFAULT_ACTION_BUTTON_WIDTH, getActionsColumnWidth, isAlert } from './helpers';
describe('isAlert', () => {
test('it returns true when the eventType is an alert', () => {
expect(isAlert('signal')).toBe(true);
});
test('it returns false when the eventType is NOT an alert', () => {
expect(isAlert('raw')).toBe(false);
});
});
describe('getActionsColumnWidth', () => {
// ideally the following implementation detail wouldn't be part of these tests,
// but without it, the test would be brittle when `euiDataGridCellPaddingM` changes:
const expectedPadding = parseInt(euiThemeVars.euiDataGridCellPaddingM, 10) * 2;
test('it returns the expected width', () => {
const ACTION_BUTTON_COUNT = 5;
const expectedContentWidth = ACTION_BUTTON_COUNT * DEFAULT_ACTION_BUTTON_WIDTH;
expect(getActionsColumnWidth(ACTION_BUTTON_COUNT)).toEqual(
expectedContentWidth + expectedPadding
);
});
test('it returns the minimum width when the button count is zero', () => {
const ACTION_BUTTON_COUNT = 0;
expect(getActionsColumnWidth(ACTION_BUTTON_COUNT)).toEqual(
DEFAULT_ACTION_BUTTON_WIDTH + expectedPadding
);
});
test('it returns the minimum width when the button count is negative', () => {
const ACTION_BUTTON_COUNT = -1;
expect(getActionsColumnWidth(ACTION_BUTTON_COUNT)).toEqual(
DEFAULT_ACTION_BUTTON_WIDTH + expectedPadding
);
});
});

View file

@ -0,0 +1,52 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { euiThemeVars } from '@kbn/ui-theme';
import type { TimelineEventsType } from '../../../../common/types';
/**
* This is the effective width in pixels of an action button used with
* `EuiDataGrid` `leadingControlColumns`. (See Notes below for details)
*
* Notes:
* 1) This constant is necessary because `width` is a required property of
* the `EuiDataGridControlColumn` interface, so it must be calculated before
* content is rendered. (The width of a `EuiDataGridControlColumn` does not
* automatically size itself to fit all the content.)
*
* 2) This is the *effective* width, because at the time of this writing,
* `EuiButtonIcon` has a `margin-left: -4px`, which is subtracted from the
* `width`
*/
export const DEFAULT_ACTION_BUTTON_WIDTH =
parseInt(euiThemeVars.euiSizeXL, 10) - parseInt(euiThemeVars.euiSizeXS, 10); // px
export const isAlert = (eventType: TimelineEventsType | Omit<TimelineEventsType, 'all'>): boolean =>
eventType === 'signal';
/**
* Returns the width of the Actions column based on the number of buttons being
* displayed
*
* NOTE: This function is necessary because `width` is a required property of
* the `EuiDataGridControlColumn` interface, so it must be calculated before
* content is rendered. (The width of a `EuiDataGridControlColumn` does not
* automatically size itself to fit all the content.)
*/
export const getActionsColumnWidth = (actionButtonCount: number): number => {
const contentWidth =
actionButtonCount > 0
? actionButtonCount * DEFAULT_ACTION_BUTTON_WIDTH
: DEFAULT_ACTION_BUTTON_WIDTH;
// `EuiDataGridRowCell` applies additional `padding-left` and
// `padding-right`, which must be added to the content width to prevent the
// content from being partially hidden due to the space occupied by padding:
const leftRightCellPadding = parseInt(euiThemeVars.euiDataGridCellPaddingM, 10) * 2; // parseInt ignores the trailing `px`, e.g. `6px`
return contentWidth + leftRightCellPadding;
};

View file

@ -5,4 +5,6 @@
* 2.0.
*/
export const getEmptyValue = () => '—';
export * from './helpers';
export * from './actions';
export * from './header_actions';

View file

@ -9,12 +9,12 @@ import { render, screen } from '@testing-library/react';
import React from 'react';
import { PinEventAction } from './pin_event_action';
import { useUserPrivileges } from '../../../../../common/components/user_privileges';
import { getEndpointPrivilegesInitialStateMock } from '../../../../../common/components/user_privileges/endpoint/mocks';
import { TestProviders } from '../../../../../common/mock';
import { TimelineType } from '../../../../../../common/types';
import { useUserPrivileges } from '../user_privileges';
import { getEndpointPrivilegesInitialStateMock } from '../user_privileges/endpoint/mocks';
import { TestProviders } from '../../mock';
import { TimelineType } from '../../../../common/types';
jest.mock('../../../../../common/components/user_privileges');
jest.mock('../user_privileges');
const useUserPrivilegesMock = useUserPrivileges as jest.Mock;
describe('PinEventAction', () => {

View file

@ -7,13 +7,12 @@
import React, { useMemo } from 'react';
import { EuiToolTip } from '@elastic/eui';
import { DEFAULT_ACTION_BUTTON_WIDTH } from '@kbn/timelines-plugin/public';
import { EventsTdContent } from '../../styles';
import { eventHasNotes, getPinTooltip } from '../helpers';
import { Pin } from '../../pin';
import type { TimelineType } from '../../../../../../common/types/timeline';
import { useUserPrivileges } from '../../../../../common/components/user_privileges';
import { EventsTdContent } from '../../../timelines/components/timeline/styles';
import { eventHasNotes, getPinTooltip } from '../../../timelines/components/timeline/body/helpers';
import type { TimelineType } from '../../../../common/types';
import { useUserPrivileges } from '../user_privileges';
import { DEFAULT_ACTION_BUTTON_WIDTH } from '.';
import { Pin } from '../../../timelines/components/timeline/pin';
interface PinEventActionProps {
ariaLabel?: string;

View file

@ -0,0 +1,144 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
export const OPEN_SESSION_VIEW = i18n.translate(
'xpack.securitySolution.timeline.body.openSessionViewLabel',
{
defaultMessage: 'Open Session View',
}
);
export const NOTES_DISABLE_TOOLTIP = i18n.translate(
'xpack.securitySolution.timeline.body.notes.disableEventTooltip',
{
defaultMessage: 'Notes may not be added here while editing a template timeline',
}
);
export const NOTES_TOOLTIP = i18n.translate(
'xpack.securitySolution.timeline.body.notes.addNoteTooltip',
{
defaultMessage: 'Add note',
}
);
export const SORT_FIELDS = i18n.translate('xpack.securitySolution.timeline.sortFieldsButton', {
defaultMessage: 'Sort fields',
});
export const FULL_SCREEN = i18n.translate('xpack.securitySolution.timeline.fullScreenButton', {
defaultMessage: 'Full screen',
});
export const VIEW_DETAILS = i18n.translate(
'xpack.securitySolution.hoverActions.viewDetailsAriaLabel',
{
defaultMessage: 'View details',
}
);
export const VIEW_DETAILS_FOR_ROW = ({
ariaRowindex,
columnValues,
}: {
ariaRowindex: number;
columnValues: string;
}) =>
i18n.translate('xpack.securitySolution.hoverActions.viewDetailsForRowAriaLabel', {
values: { ariaRowindex, columnValues },
defaultMessage:
'View details for the alert or event in row {ariaRowindex}, with columns {columnValues}',
});
export const ACTION_INVESTIGATE_IN_RESOLVER = i18n.translate(
'xpack.securitySolution.hoverActions.investigateInResolverTooltip',
{
defaultMessage: 'Analyze event',
}
);
export const CHECKBOX_FOR_ROW = ({
ariaRowindex,
columnValues,
checked,
}: {
ariaRowindex: number;
columnValues: string;
checked: boolean;
}) =>
i18n.translate('xpack.securitySolution.hoverActions.checkboxForRowAriaLabel', {
values: { ariaRowindex, checked, columnValues },
defaultMessage:
'{checked, select, false {unchecked} true {checked}} checkbox for the alert or event in row {ariaRowindex}, with columns {columnValues}',
});
export const ACTION_INVESTIGATE_IN_RESOLVER_FOR_ROW = ({
ariaRowindex,
columnValues,
}: {
ariaRowindex: number;
columnValues: string;
}) =>
i18n.translate('xpack.securitySolution.hoverActions.investigateInResolverForRowAriaLabel', {
values: { ariaRowindex, columnValues },
defaultMessage: 'Analyze the alert or event in row {ariaRowindex}, with columns {columnValues}',
});
export const SEND_ALERT_TO_TIMELINE_FOR_ROW = ({
ariaRowindex,
columnValues,
}: {
ariaRowindex: number;
columnValues: string;
}) =>
i18n.translate('xpack.securitySolution.hoverActions.sendAlertToTimelineForRowAriaLabel', {
values: { ariaRowindex, columnValues },
defaultMessage: 'Send the alert in row {ariaRowindex} to timeline, with columns {columnValues}',
});
export const ADD_NOTES_FOR_ROW = ({
ariaRowindex,
columnValues,
}: {
ariaRowindex: number;
columnValues: string;
}) =>
i18n.translate('xpack.securitySolution.hoverActions.addNotesForRowAriaLabel', {
values: { ariaRowindex, columnValues },
defaultMessage:
'Add notes for the event in row {ariaRowindex} to timeline, with columns {columnValues}',
});
export const PIN_EVENT_FOR_ROW = ({
ariaRowindex,
columnValues,
isEventPinned,
}: {
ariaRowindex: number;
columnValues: string;
isEventPinned: boolean;
}) =>
i18n.translate('xpack.securitySolution.hoverActions.pinEventForRowAriaLabel', {
values: { ariaRowindex, columnValues, isEventPinned },
defaultMessage:
'{isEventPinned, select, false {Pin} true {Unpin}} the event in row {ariaRowindex} to timeline, with columns {columnValues}',
});
export const MORE_ACTIONS_FOR_ROW = ({
ariaRowindex,
columnValues,
}: {
ariaRowindex: number;
columnValues: string;
}) =>
i18n.translate('xpack.securitySolution.hoverActions.moreActionsForRowAriaLabel', {
values: { ariaRowindex, columnValues },
defaultMessage:
'Select more actions for the alert or event in row {ariaRowindex}, with columns {columnValues}',
});

View file

@ -7,13 +7,13 @@
import React, { useCallback, useMemo, useState, useRef, useContext } from 'react';
import type { DraggableProvided, DraggableStateSnapshot } from 'react-beautiful-dnd';
import { TableContext } from '@kbn/timelines-plugin/public';
import { TimelineContext } from '../../../timelines/components/timeline';
import { HoverActions } from '.';
import type { DataProvider } from '../../../../common/types';
import { ProviderContentWrapper } from '../drag_and_drop/draggable_wrapper';
import { getDraggableId } from '../drag_and_drop/helpers';
import { TableContext } from '../events_viewer/shared';
import { useTopNPopOver } from './utils';
const draggableContainsLinks = (draggableElement: HTMLDivElement | null) => {

View file

@ -23,7 +23,6 @@ import { upsertQuery } from '../../store/inputs/helpers';
import { InspectButton } from '.';
import { cloneDeep } from 'lodash/fp';
import { InputsModelId } from '../../store/inputs/constants';
import { tGridReducer } from '@kbn/timelines-plugin/public';
jest.mock('./modal', () => ({
ModalInspectQuery: jest.fn(() => <div data-test-subj="mocker-modal" />),
@ -42,13 +41,7 @@ describe('Inspect Button', () => {
state: state.inputs,
};
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
describe('Render', () => {
beforeEach(() => {
@ -57,7 +50,7 @@ describe('Inspect Button', () => {
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
@ -166,13 +159,7 @@ describe('Inspect Button', () => {
const myQuery = cloneDeep(newQuery);
myQuery.inspect = null;
myState.inputs = upsertQuery(myQuery);
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<InspectButton queryId={newQuery.id} title="My title" />
@ -189,13 +176,7 @@ describe('Inspect Button', () => {
response: ['my response'],
};
myState.inputs = upsertQuery(myQuery);
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<InspectButton queryId={newQuery.id} title="My title" />
@ -212,13 +193,7 @@ describe('Inspect Button', () => {
response: [],
};
myState.inputs = upsertQuery(myQuery);
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<InspectButton queryId={newQuery.id} title="My title" />
@ -237,13 +212,7 @@ describe('Inspect Button', () => {
response: ['my response'],
};
myState.inputs = upsertQuery(myQuery);
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
});
test('Open Inspect Modal', () => {
const wrapper = mount(
@ -288,13 +257,7 @@ describe('Inspect Button', () => {
};
myState.inputs = upsertQuery(myQuery);
myState.inputs.global.queries[0].isInspected = true;
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<InspectButton queryId={newQuery.id} title="My title" />
@ -313,13 +276,7 @@ describe('Inspect Button', () => {
};
myState.inputs = upsertQuery(myQuery);
myState.inputs.global.queries[0].isInspected = false;
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<InspectButton queryId={newQuery.id} title="My title" />
@ -338,13 +295,7 @@ describe('Inspect Button', () => {
};
myState.inputs = upsertQuery(myQuery);
myState.inputs.global.queries[0].isInspected = true;
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<InspectButton queryId={newQuery.id} title="My title" />
@ -363,13 +314,7 @@ describe('Inspect Button', () => {
};
myState.inputs = upsertQuery(myQuery);
myState.inputs.global.queries[0].isInspected = true;
store = createStore(
myState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(myState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<InspectButton queryId={newQuery.id} title="My title" />

View file

@ -18,17 +18,10 @@ import { createStore } from '../../../store';
import type { State } from '../../../store';
import type { SecurityJob } from '../types';
import { tGridReducer } from '@kbn/timelines-plugin/public';
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<TestProviders store={store}>{children}</TestProviders>

View file

@ -5,7 +5,6 @@
* 2.0.
*/
import React from 'react';
import { tGridReducer } from '@kbn/timelines-plugin/public';
import type { RenderHookResult } from '@testing-library/react-hooks';
import { renderHook } from '@testing-library/react-hooks';
import {
@ -26,13 +25,7 @@ import type { Refetch } from '../../store/inputs/model';
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = ({ children }: { children: React.ReactNode }) => (
<TestProviders store={store}>{children}</TestProviders>

View file

@ -8,7 +8,7 @@
import { EuiLink } from '@elastic/eui';
import { isEmpty } from 'lodash';
import React, { useCallback, useMemo } from 'react';
import { CoreStart } from '@kbn/core/public';
import type { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
interface RuleNameProps {

View file

@ -21,7 +21,6 @@ import { coreMock } from '@kbn/core/public/mocks';
import { createStore } from '../../store';
import { inputsActions } from '../../store/inputs';
import { InputsModelId } from '../../store/inputs/constants';
import { tGridReducer } from '@kbn/timelines-plugin/public';
const mockSetAppFilters = jest.fn();
const mockFilterManager = new FilterManager(coreMock.createStart().uiSettings);
@ -138,13 +137,7 @@ describe('SearchBarComponent', () => {
};
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
render(
<TestProviders store={store}>
@ -180,13 +173,7 @@ describe('SearchBarComponent', () => {
};
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
render(
<TestProviders store={store}>
@ -201,13 +188,7 @@ describe('SearchBarComponent', () => {
it('calls useUpdateUrlParam when query query changes', async () => {
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
render(
<TestProviders store={store}>
@ -232,13 +213,7 @@ describe('SearchBarComponent', () => {
it('calls useUpdateUrlParam when filters change', async () => {
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
render(
<TestProviders store={store}>
@ -274,13 +249,7 @@ describe('SearchBarComponent', () => {
it('calls useUpdateUrlParam when savedQuery changes', async () => {
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
render(
<TestProviders store={store}>
@ -313,13 +282,7 @@ describe('SearchBarComponent', () => {
describe('Timerange', () => {
it('calls useUpdateUrlParam when global timerange changes', async () => {
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
render(
<TestProviders store={store}>
@ -355,13 +318,7 @@ describe('SearchBarComponent', () => {
it('calls useUpdateUrlParam when timeline timerange changes', async () => {
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
render(
<TestProviders store={store}>
@ -400,13 +357,7 @@ describe('SearchBarComponent', () => {
it('initializes timerange URL param with redux date on mount', async () => {
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
jest.clearAllMocks();
render(
<TestProviders store={store}>

View file

@ -2,98 +2,50 @@
exports[`SessionsView renders correctly against snapshot 1`] = `
<DocumentFragment>
.c1 {
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-flex: 1;
-webkit-flex-grow: 1;
-ms-flex-positive: 1;
flex-grow: 1;
}
.c1 > * {
max-width: 100%;
}
.c1 .inspectButtonComponent {
pointer-events: none;
opacity: 0;
-webkit-transition: opacity 250ms ease;
transition: opacity 250ms ease;
}
.c1:hover .inspectButtonComponent {
pointer-events: auto;
opacity: 1;
}
.c0 {
-webkit-flex: 1 1 auto;
-ms-flex: 1 1 auto;
flex: 1 1 auto;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
width: 100%;
}
<div
<div
data-test-subj="security_solution:sessions_viewer:sessions_view"
>
<div
class="c0"
>
<div>
<div
class="c1"
data-test-subj="hoverVisibilityContainer"
data-test-subj="security_solution:sessions_viewer:sessions_view:entityType"
>
<div>
<div
data-test-subj="security_solution:sessions_viewer:sessions_view:entityType"
>
sessions
</div>
<div
data-test-subj="security_solution:sessions_viewer:sessions_view:startDate"
>
2022-03-22T22:10:56.794Z
</div>
<div
data-test-subj="security_solution:sessions_viewer:sessions_view:endDate"
>
2022-03-21T22:10:56.791Z
</div>
<div
data-test-subj="security_solution:sessions_viewer:sessions_view:timelineId"
>
hosts-page-sessions-v2
</div>
<div>
Started
</div>
<div>
Executable
</div>
<div>
User
</div>
<div>
Interactive
</div>
<div>
Hostname
</div>
<div>
Type
</div>
<div>
Source IP
</div>
</div>
sessions
</div>
<div
data-test-subj="security_solution:sessions_viewer:sessions_view:startDate"
>
2022-03-22T22:10:56.794Z
</div>
<div
data-test-subj="security_solution:sessions_viewer:sessions_view:endDate"
>
2022-03-21T22:10:56.791Z
</div>
<div
data-test-subj="security_solution:sessions_viewer:sessions_view:timelineId"
>
hosts-page-sessions-v2
</div>
<div>
Started
</div>
<div>
Executable
</div>
<div>
User
</div>
<div>
Interactive
</div>
<div>
Hostname
</div>
<div>
Type
</div>
<div>
Source IP
</div>
</div>
</div>

View file

@ -6,7 +6,7 @@
*/
import { tableDefaults } from '../../store/data_table/defaults';
import type { SubsetTGridModel } from '../../store/data_table/model';
import type { SubsetDataTableModel } from '../../store/data_table/model';
import type { ColumnHeaderOptions } from '../../../../common/types/timeline';
import { defaultColumnHeaderType } from '../../../timelines/components/timeline/body/column_headers/default_headers';
import { DEFAULT_DATE_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants';
@ -62,7 +62,7 @@ export const sessionsHeaders: ColumnHeaderOptions[] = [
export const getSessionsDefaultModel = (
columns: ColumnHeaderOptions[],
defaultColumns: ColumnHeaderOptions[]
): SubsetTGridModel => ({
): SubsetDataTableModel => ({
...tableDefaults,
columns,
defaultColumns,

View file

@ -11,10 +11,11 @@ import { TestProviders } from '../../mock';
import { TEST_ID, SessionsView, defaultSessionsFilter } from '.';
import type { EntityType } from '@kbn/timelines-plugin/common';
import type { SessionsComponentsProps } from './types';
import type { TimelineModel } from '../../../timelines/store/timeline/model';
import { useGetUserCasesPermissions } from '../../lib/kibana';
import { TableId } from '../../../../common/types';
import { licenseService } from '../../hooks/use_license';
import { mount } from 'enzyme';
import type { EventsViewerProps } from '../events_viewer';
jest.mock('../../lib/kibana');
@ -42,7 +43,7 @@ const testProps: SessionsComponentsProps = {
filterQuery,
};
type Props = Partial<TimelineModel> & {
type Props = Partial<EventsViewerProps> & {
start: string;
end: string;
entityType: EntityType;
@ -70,47 +71,58 @@ jest.mock('../../hooks/use_license', () => {
};
});
// creating a dummy component for testing TGrid to avoid mocking all the implementation details
// but still test if the TGrid will render properly
const SessionsViewerTGrid: React.FC<Props> = ({ columns, start, end, id, filters, entityType }) => {
// creating a dummy component for testing data table to avoid mocking all the implementation details
// but still test if the data table will render properly
const SessionsViewerEventsViewer: React.FC<Props> = ({
defaultModel,
start,
end,
tableId,
pageFilters,
entityType,
}) => {
useEffect(() => {
callFilters(filters);
}, [filters]);
callFilters(pageFilters);
}, [pageFilters]);
return (
<div>
<div data-test-subj={`${TEST_PREFIX}:entityType`}>{entityType}</div>
<div data-test-subj={`${TEST_PREFIX}:startDate`}>{start}</div>
<div data-test-subj={`${TEST_PREFIX}:endDate`}>{end}</div>
<div data-test-subj={`${TEST_PREFIX}:timelineId`}>{id}</div>
{columns?.map((header) => (
<div data-test-subj={`${TEST_PREFIX}:timelineId`}>{tableId}</div>
{defaultModel?.columns?.map((header) => (
<div key={header.id}>{header.display ?? header.id}</div>
))}
</div>
);
};
jest.mock('@kbn/timelines-plugin/public/mock/plugin_mock', () => {
const originalModule = jest.requireActual('@kbn/timelines-plugin/public/mock/plugin_mock');
jest.mock('../events_viewer', () => {
return {
...originalModule,
createTGridMocks: () => ({
...originalModule.createTGridMocks,
getTGrid: SessionsViewerTGrid,
}),
StatefulEventsViewer: SessionsViewerEventsViewer,
};
});
mockGetDefaultControlColumn.mockReturnValue([
{
headerCellRender: () => <></>,
id: 'default-timeline-control-column',
rowCellRender: jest.fn(),
width: jest.fn(),
},
]);
describe('SessionsView', () => {
it('renders the session view', async () => {
const wrapper = render(
const wrapper = mount(
<TestProviders>
<SessionsView {...testProps} />
</TestProviders>
);
await waitFor(() => {
expect(wrapper.queryByTestId(TEST_ID)).toBeInTheDocument();
expect(wrapper.find(`[data-test-subj="${TEST_ID}"]`).exists()).toBeTruthy();
});
});
@ -126,7 +138,7 @@ describe('SessionsView', () => {
});
});
it('passes in the right parameters to TGrid', async () => {
it('passes in the right parameters to EventsViewer', async () => {
const wrapper = render(
<TestProviders>
<SessionsView {...testProps} />
@ -142,7 +154,7 @@ describe('SessionsView', () => {
});
});
it('passes in the right filters to TGrid', async () => {
it('passes in the right filters to EventsViewer', async () => {
render(
<TestProviders>
<SessionsView {...testProps} />

View file

@ -8,9 +8,9 @@
import React, { useMemo, useEffect } from 'react';
import type { Filter } from '@kbn/es-query';
import { ENTRY_SESSION_ENTITY_ID_PROPERTY, EventAction } from '@kbn/session-view-plugin/public';
import type { BulkActionsProp } from '@kbn/timelines-plugin/common/types';
import { useDispatch } from 'react-redux';
import { EVENT_ACTION } from '@kbn/rule-data-utils';
import { TableId } from '../../../../common/types';
import { useAddBulkToTimelineAction } from '../../../detections/components/alerts_table/timeline_actions/use_add_bulk_to_timeline';
import type { SessionsComponentsProps } from './types';
import type { ESBoolQuery } from '../../../../common/typed_json';
@ -22,11 +22,11 @@ import * as i18n from './translations';
import { SourcererScopeName } from '../../store/sourcerer/model';
import { getDefaultControlColumn } from '../../../timelines/components/timeline/body/control_columns';
import { useLicense } from '../../hooks/use_license';
import { TableId } from '../../../../common/types/timeline';
import { dataTableActions } from '../../store/data_table';
import { eventsDefaultModel } from '../events_viewer/default_model';
import { useIsExperimentalFeatureEnabled } from '../../hooks/use_experimental_features';
import { DEFAULT_COLUMN_MIN_WIDTH } from '../../../timelines/components/timeline/body/constants';
import type { BulkActionsProp } from '../toolbar/bulk_actions/types';
export const TEST_ID = 'security_solution:sessions_viewer:sessions_view';
@ -104,7 +104,7 @@ const SessionsViewComponent: React.FC<SessionsComponentsProps> = ({
useEffect(() => {
dispatch(
dataTableActions.initializeTGridSettings({
dataTableActions.initializeDataTableSettings({
id: tableId,
title: i18n.SESSIONS_TITLE,
defaultColumns: eventsDefaultModel.columns.map((c) =>
@ -159,7 +159,7 @@ const SessionsViewComponent: React.FC<SessionsComponentsProps> = ({
leadingControlColumns={leadingControlColumns}
renderCellValue={DefaultCellRenderer}
rowRenderers={defaultRowRenderers}
scopeId={SourcererScopeName.default}
sourcererScope={SourcererScopeName.default}
start={startDate}
unit={unit}
/>

View file

@ -6,8 +6,9 @@
*/
import type { Filter } from '@kbn/es-query';
import type { EntityType } from '@kbn/timelines-plugin/common';
import type { TableIdLiteral } from '../../../../common/types';
import type { QueryTabBodyProps } from '../../../hosts/pages/navigation/types';
import type { ColumnHeaderOptions, TableIdLiteral } from '../../../../common/types/timeline';
import type { ColumnHeaderOptions } from '../../../../common/types/timeline';
export interface SessionsComponentsProps extends Pick<QueryTabBodyProps, 'endDate' | 'startDate'> {
tableId: TableIdLiteral;

View file

@ -28,7 +28,6 @@ import { useSignalHelpers } from '../../containers/sourcerer/use_signal_helpers'
import { TimelineId, TimelineType } from '../../../../common/types';
import { DEFAULT_INDEX_PATTERN } from '../../../../common/constants';
import { sortWithExcludesAtEnd } from '../../../../common/utils/sourcerer';
import { tGridReducer } from '@kbn/timelines-plugin/public';
const mockDispatch = jest.fn();
@ -95,13 +94,7 @@ describe('Sourcerer component', () => {
const pollForSignalIndexMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
(useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView);
(useSignalHelpers as jest.Mock).mockReturnValue({ signalIndexNeedsInit: false });
});
@ -213,7 +206,6 @@ describe('Sourcerer component', () => {
},
},
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
@ -264,7 +256,6 @@ describe('Sourcerer component', () => {
},
},
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
@ -316,13 +307,7 @@ describe('Sourcerer component', () => {
},
};
store = createStore(
state2,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<Sourcerer {...defaultProps} />
@ -367,13 +352,7 @@ describe('Sourcerer component', () => {
},
};
store = createStore(
state2,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
@ -413,7 +392,6 @@ describe('Sourcerer component', () => {
},
},
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
@ -471,7 +449,6 @@ describe('Sourcerer component', () => {
},
},
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
@ -540,7 +517,6 @@ describe('Sourcerer component', () => {
},
},
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
@ -585,13 +561,7 @@ describe('Sourcerer component', () => {
},
};
store = createStore(
state2,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<Sourcerer scope={sourcererModel.SourcererScopeName.timeline} />
@ -635,13 +605,7 @@ describe('Sourcerer component', () => {
},
};
store = createStore(
state2,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const wrapper = mount(
<TestProviders store={store}>
<Sourcerer {...defaultProps} />
@ -720,13 +684,7 @@ describe('Sourcerer component', () => {
describe('sourcerer on alerts page or rules details page', () => {
let wrapper: ReactWrapper;
const { storage } = createSecuritySolutionStorageMock();
store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const testProps = {
scope: sourcererModel.SourcererScopeName.detections,
};
@ -795,13 +753,7 @@ describe('sourcerer on alerts page or rules details page', () => {
describe('timeline sourcerer', () => {
let wrapper: ReactWrapper;
const { storage } = createSecuritySolutionStorageMock();
store = createStore(
mockGlobalState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(mockGlobalState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const testProps = {
scope: sourcererModel.SourcererScopeName.timeline,
};
@ -894,7 +846,7 @@ describe('timeline sourcerer', () => {
store = createStore(
state2,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
@ -941,13 +893,7 @@ describe('Sourcerer integration tests', () => {
beforeEach(() => {
(useSourcererDataView as jest.Mock).mockReturnValue(sourcererDataView);
store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
jest.clearAllMocks();
jest.restoreAllMocks();
});
@ -992,13 +938,7 @@ describe('No data', () => {
...sourcererDataView,
indicesExist: false,
});
store = createStore(
mockNoIndicesState,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(mockNoIndicesState, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
jest.clearAllMocks();
jest.restoreAllMocks();
});
@ -1073,13 +1013,7 @@ describe('Update available', () => {
...sourcererDataView,
activePatterns: ['myFakebeat-*'],
});
store = createStore(
state2,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
wrapper = mount(
<TestProviders store={store}>
@ -1208,13 +1142,7 @@ describe('Update available for timeline template', () => {
...sourcererDataView,
activePatterns: ['myFakebeat-*'],
});
store = createStore(
state2,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
wrapper = mount(
<TestProviders store={store}>
@ -1299,13 +1227,7 @@ describe('Missing index patterns', () => {
});
const state3 = cloneDeep(state2);
state3.timeline.timelineById[TimelineId.active].timelineType = TimelineType.default;
store = createStore(
state3,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state3, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
wrapper = mount(
<TestProviders store={store}>
@ -1341,13 +1263,7 @@ describe('Missing index patterns', () => {
...sourcererDataView,
activePatterns: ['myFakebeat-*'],
});
store = createStore(
state2,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state2, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
wrapper = mount(
<TestProviders store={store}>

View file

@ -27,7 +27,6 @@ import { createStore } from '../../store';
import { Provider as ReduxStoreProvider } from 'react-redux';
import { getMockTheme } from '../../lib/kibana/kibana_react.mock';
import * as module from '../../containers/query_toggle';
import { tGridReducer } from '@kbn/timelines-plugin/public';
import type { LensAttributes } from '../visualization_actions/types';
const from = '2019-06-15T06:00:00.000Z';
@ -58,13 +57,7 @@ describe('Stat Items Component', () => {
const mockTheme = getMockTheme({ eui: { euiColorMediumShade: '#ece' } });
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
const store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
const store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
const testProps = {
description: 'HOSTS',
fields: [{ key: 'hosts', value: null, color: '#6092C0', icon: 'cross' }],

View file

@ -24,7 +24,6 @@ import { createStore } from '../../store';
import { SuperDatePicker, makeMapStateToProps } from '.';
import { cloneDeep } from 'lodash/fp';
import { InputsModelId } from '../../store/inputs/constants';
import { tGridReducer } from '@kbn/timelines-plugin/public';
jest.mock('../../lib/kibana');
const mockUseUiSetting$ = useUiSetting$ as jest.Mock;
@ -85,23 +84,11 @@ describe('SIEM Super Date Picker', () => {
describe('#SuperDatePicker', () => {
const state: State = mockGlobalState;
const { storage } = createSecuritySolutionStorageMock();
let store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
let store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
beforeEach(() => {
jest.clearAllMocks();
store = createStore(
state,
SUB_PLUGINS_REDUCER,
{ dataTable: tGridReducer },
kibanaObservable,
storage
);
store = createStore(state, SUB_PLUGINS_REDUCER, kibanaObservable, storage);
mockUseUiSetting$.mockImplementation((key, defaultValue) => {
const useUiSetting$Mock = createUseUiSetting$Mock();

View file

@ -6,24 +6,25 @@
*/
import React, { useCallback, useEffect, useState } from 'react';
import { connect, ConnectedProps, useDispatch } from 'react-redux';
import type { ConnectedProps } from 'react-redux';
import { connect, useDispatch } from 'react-redux';
import type {
AlertStatus,
SetEventsLoading,
SetEventsDeleted,
OnUpdateAlertStatusSuccess,
OnUpdateAlertStatusError,
CustomBulkActionProp,
SetEventsDeleted,
SetEventsLoading,
} from '../../../../../common/types';
import type { Refetch } from '../../../../store/t_grid/inputs';
import { TableState, tGridActions, TGridModel, tGridSelectors } from '../../../../store/t_grid';
import { BulkActions } from '.';
import { useBulkActionItems } from '../../../../hooks/use_bulk_action_items';
import { useBulkActionItems } from './use_bulk_action_items';
import { dataTableActions, dataTableSelectors } from '../../../store/data_table';
import type { DataTableModel } from '../../../store/data_table/model';
import type { AlertWorkflowStatus, Refetch } from '../../../types';
import type { DataTableState } from '../../../store/data_table/types';
import type { OnUpdateAlertStatusError, OnUpdateAlertStatusSuccess } from './types';
interface OwnProps {
id: string;
totalItems: number;
filterStatus?: AlertStatus;
filterStatus?: AlertWorkflowStatus;
query?: string;
indexName: string;
showAlertStatusActions?: boolean;
@ -61,7 +62,7 @@ export const AlertBulkActionsComponent = React.memo<StatefulAlertBulkActionsProp
// Catches state change isSelectAllChecked->false (page checkbox) upon user selection change to reset toolbar select all
useEffect(() => {
if (isSelectAllChecked) {
dispatch(tGridActions.setTGridSelectAll({ id, selectAll: false }));
dispatch(dataTableActions.setDataTableSelectAll({ id, selectAll: false }));
} else {
setShowClearSelection(false);
}
@ -71,19 +72,19 @@ export const AlertBulkActionsComponent = React.memo<StatefulAlertBulkActionsProp
// Dispatches to stateful_body's selectAll via TimelineTypeContext props
// as scope of response data required to actually set selectedEvents
const onSelectAll = useCallback(() => {
dispatch(tGridActions.setTGridSelectAll({ id, selectAll: true }));
dispatch(dataTableActions.setDataTableSelectAll({ id, selectAll: true }));
setShowClearSelection(true);
}, [dispatch, id]);
// Callback for clearing entire selection from toolbar
const onClearSelection = useCallback(() => {
clearSelected({ id });
dispatch(tGridActions.setTGridSelectAll({ id, selectAll: false }));
dispatch(dataTableActions.setDataTableSelectAll({ id, selectAll: false }));
setShowClearSelection(false);
}, [clearSelected, dispatch, id]);
const onUpdateSuccess = useCallback(
(updated: number, conflicts: number, newStatus: AlertStatus) => {
(updated: number, conflicts: number, newStatus: AlertWorkflowStatus) => {
refetch();
if (onActionSuccess) {
onActionSuccess(updated, conflicts, newStatus);
@ -93,7 +94,7 @@ export const AlertBulkActionsComponent = React.memo<StatefulAlertBulkActionsProp
);
const onUpdateFailure = useCallback(
(newStatus: AlertStatus, error: Error) => {
(newStatus: AlertWorkflowStatus, error: Error) => {
refetch();
if (onActionFailure) {
onActionFailure(newStatus, error);
@ -104,14 +105,14 @@ export const AlertBulkActionsComponent = React.memo<StatefulAlertBulkActionsProp
const setEventsLoading = useCallback<SetEventsLoading>(
({ eventIds, isLoading }) => {
dispatch(tGridActions.setEventsLoading({ id, eventIds, isLoading }));
dispatch(dataTableActions.setEventsLoading({ id, eventIds, isLoading }));
},
[dispatch, id]
);
const setEventsDeleted = useCallback<SetEventsDeleted>(
({ eventIds, isDeleted }) => {
dispatch(tGridActions.setEventsDeleted({ id, eventIds, isDeleted }));
dispatch(dataTableActions.setEventsDeleted({ id, eventIds, isDeleted }));
},
[dispatch, id]
);
@ -146,9 +147,9 @@ export const AlertBulkActionsComponent = React.memo<StatefulAlertBulkActionsProp
AlertBulkActionsComponent.displayName = 'AlertBulkActionsComponent';
const makeMapStateToProps = () => {
const getTGrid = tGridSelectors.getTGridByIdSelector();
const mapStateToProps = (state: TableState, { id }: OwnProps) => {
const dataTable: TGridModel = getTGrid(state, id);
const getTable = dataTableSelectors.getTableByIdSelector();
const mapStateToProps = (state: DataTableState, { id }: OwnProps) => {
const dataTable: DataTableModel = getTable(state, id);
const { selectedEventIds, isSelectAllChecked } = dataTable;
return {
@ -160,7 +161,7 @@ const makeMapStateToProps = () => {
};
const mapDispatchToProps = {
clearSelected: tGridActions.clearSelected,
clearSelected: dataTableActions.clearSelected,
};
const connector = connect(makeMapStateToProps, mapDispatchToProps);

View file

@ -13,7 +13,7 @@ import { useUiSetting$ } from '@kbn/kibana-react-plugin/public';
import { DEFAULT_NUMBER_FORMAT } from '../../../../../common/constants';
import * as i18n from './translations';
interface BulkActionsProps {
interface OwnProps {
totalItems: number;
selectedCount: number;
showClearSelection: boolean;
@ -32,7 +32,7 @@ BulkActionsContainer.displayName = 'BulkActionsContainer';
/**
* Stateless component integrating the bulk actions menu and the select all button
*/
const BulkActionsComponent: React.FC<BulkActionsProps> = ({
const BulkActionsComponent: React.FC<OwnProps> = ({
selectedCount,
totalItems,
showClearSelection,

View file

@ -7,112 +7,107 @@
import { i18n } from '@kbn/i18n';
export const EVENTS_TABLE_ARIA_LABEL = ({
activePage,
totalPages,
}: {
activePage: number;
totalPages: number;
}) =>
i18n.translate('xpack.timelines.timeline.eventsTableAriaLabel', {
values: { activePage, totalPages },
defaultMessage: 'events; Page {activePage} of {totalPages}',
});
export const BULK_ACTION_OPEN_SELECTED = i18n.translate(
'xpack.timelines.timeline.openSelectedTitle',
{
defaultMessage: 'Mark as open',
}
);
export const BULK_ACTION_CLOSE_SELECTED = i18n.translate(
'xpack.timelines.timeline.closeSelectedTitle',
{
defaultMessage: 'Mark as closed',
}
);
export const BULK_ACTION_ACKNOWLEDGED_SELECTED = i18n.translate(
'xpack.timelines.timeline.acknowledgedSelectedTitle',
{
defaultMessage: 'Mark as acknowledged',
}
);
export const BULK_ACTION_FAILED_SINGLE_ALERT = i18n.translate(
'xpack.timelines.timeline.updateAlertStatusFailedSingleAlert',
{
defaultMessage: 'Failed to update alert because it was already being modified.',
}
);
export const BULK_ACTION_ATTACH_NEW_CASE = i18n.translate(
'xpack.timelines.timeline.attachNewCase',
{
defaultMessage: 'Attach to new case',
}
);
export const BULK_ACTION_ATTACH_EXISTING_CASE = i18n.translate(
'xpack.timelines.timeline.attachExistingCase',
{
defaultMessage: 'Attach to existing case',
}
);
export const CLOSED_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.timelines.timeline.closedAlertSuccessToastMessage', {
values: { totalAlerts },
export const SELECTED_ALERTS = (selectedAlertsFormatted: string, selectedAlerts: number) =>
i18n.translate('xpack.securitySolution.toolbar.bulkActions.selectedAlertsTitle', {
values: { selectedAlertsFormatted, selectedAlerts },
defaultMessage:
'Successfully closed {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.',
'Selected {selectedAlertsFormatted} {selectedAlerts, plural, =1 {alert} other {alerts}}',
});
export const OPENED_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.timelines.timeline.openedAlertSuccessToastMessage', {
values: { totalAlerts },
export const SELECT_ALL_ALERTS = (totalAlertsFormatted: string, totalAlerts: number) =>
i18n.translate('xpack.securitySolution.toolbar.bulkActions.selectAllAlertsTitle', {
values: { totalAlertsFormatted, totalAlerts },
defaultMessage:
'Successfully opened {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.',
'Select all {totalAlertsFormatted} {totalAlerts, plural, =1 {alert} other {alerts}}',
});
export const ACKNOWLEDGED_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.timelines.timeline.acknowledgedAlertSuccessToastMessage', {
values: { totalAlerts },
defaultMessage:
'Successfully marked {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}} as acknowledged.',
});
export const CLOSED_ALERT_FAILED_TOAST = i18n.translate(
'xpack.timelines.timeline.closedAlertFailedToastMessage',
export const CLEAR_SELECTION = i18n.translate(
'xpack.securitySolution.toolbar.bulkActions.clearSelectionTitle',
{
defaultMessage: 'Failed to close alert(s).',
}
);
export const OPENED_ALERT_FAILED_TOAST = i18n.translate(
'xpack.timelines.timeline.openedAlertFailedToastMessage',
{
defaultMessage: 'Failed to open alert(s)',
}
);
export const ACKNOWLEDGED_ALERT_FAILED_TOAST = i18n.translate(
'xpack.timelines.timeline.acknowledgedAlertFailedToastMessage',
{
defaultMessage: 'Failed to mark alert(s) as acknowledged',
defaultMessage: 'Clear selection',
}
);
export const UPDATE_ALERT_STATUS_FAILED = (conflicts: number) =>
i18n.translate('xpack.timelines.timeline.updateAlertStatusFailed', {
i18n.translate('xpack.securitySolution.bulkActions.updateAlertStatusFailed', {
values: { conflicts },
defaultMessage:
'Failed to update { conflicts } {conflicts, plural, =1 {alert} other {alerts}}.',
});
export const UPDATE_ALERT_STATUS_FAILED_DETAILED = (updated: number, conflicts: number) =>
i18n.translate('xpack.timelines.timeline.updateAlertStatusFailedDetailed', {
i18n.translate('xpack.securitySolution.bulkActions.updateAlertStatusFailedDetailed', {
values: { updated, conflicts },
defaultMessage: `{ updated } {updated, plural, =1 {alert was} other {alerts were}} updated successfully, but { conflicts } failed to update
because { conflicts, plural, =1 {it was} other {they were}} already being modified.`,
});
export const CLOSED_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.securitySolution.bulkActions.closedAlertSuccessToastMessage', {
values: { totalAlerts },
defaultMessage:
'Successfully closed {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.',
});
export const OPENED_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.securitySolution.bulkActions.openedAlertSuccessToastMessage', {
values: { totalAlerts },
defaultMessage:
'Successfully opened {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}}.',
});
export const ACKNOWLEDGED_ALERT_SUCCESS_TOAST = (totalAlerts: number) =>
i18n.translate('xpack.securitySolution.bulkActions.acknowledgedAlertSuccessToastMessage', {
values: { totalAlerts },
defaultMessage:
'Successfully marked {totalAlerts} {totalAlerts, plural, =1 {alert} other {alerts}} as acknowledged.',
});
export const CLOSED_ALERT_FAILED_TOAST = i18n.translate(
'xpack.securitySolution.bulkActions.closedAlertFailedToastMessage',
{
defaultMessage: 'Failed to close alert(s).',
}
);
export const OPENED_ALERT_FAILED_TOAST = i18n.translate(
'xpack.securitySolution.bulkActions.openedAlertFailedToastMessage',
{
defaultMessage: 'Failed to open alert(s)',
}
);
export const ACKNOWLEDGED_ALERT_FAILED_TOAST = i18n.translate(
'xpack.securitySolution.bulkActions.acknowledgedAlertFailedToastMessage',
{
defaultMessage: 'Failed to mark alert(s) as acknowledged',
}
);
export const BULK_ACTION_FAILED_SINGLE_ALERT = i18n.translate(
'xpack.securitySolution.bulkActions.updateAlertStatusFailedSingleAlert',
{
defaultMessage: 'Failed to update alert because it was already being modified.',
}
);
export const BULK_ACTION_OPEN_SELECTED = i18n.translate(
'xpack.securitySolution.bulkActions.openSelectedTitle',
{
defaultMessage: 'Mark as open',
}
);
export const BULK_ACTION_ACKNOWLEDGED_SELECTED = i18n.translate(
'xpack.securitySolution.bulkActions.acknowledgedSelectedTitle',
{
defaultMessage: 'Mark as acknowledged',
}
);
export const BULK_ACTION_CLOSE_SELECTED = i18n.translate(
'xpack.securitySolution.bulkActions.closeSelectedTitle',
{
defaultMessage: 'Mark as closed',
}
);

View file

@ -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
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { CustomBulkAction } from '../../../../../common/types';
import type { AlertWorkflowStatus } from '../../../types';
export interface BulkActionsObjectProp {
alertStatusActions?: boolean;
onAlertStatusActionSuccess?: OnUpdateAlertStatusSuccess;
onAlertStatusActionFailure?: OnUpdateAlertStatusError;
customBulkActions?: CustomBulkAction[];
}
export type OnUpdateAlertStatusSuccess = (
updated: number,
conflicts: number,
status: AlertWorkflowStatus
) => void;
export type OnUpdateAlertStatusError = (status: AlertWorkflowStatus, error: Error) => void;
export type BulkActionsProp = boolean | BulkActionsObjectProp;

View file

@ -7,18 +7,37 @@
import React, { useMemo, useCallback } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import { FILTER_CLOSED, FILTER_ACKNOWLEDGED, FILTER_OPEN } from '../../common/constants';
import * as i18n from '../components/t_grid/translations';
import type { AlertStatus, BulkActionsProps } from '../../common/types/timeline';
import { useUpdateAlertsStatus } from '../container/use_update_alerts';
import { useAppToasts } from './use_app_toasts';
import { useStartTransaction } from '../lib/apm/use_start_transaction';
import { APM_USER_INTERACTIONS } from '../lib/apm/constants';
import { FILTER_ACKNOWLEDGED, FILTER_CLOSED, FILTER_OPEN } from '../../../../../common/types';
import type {
CustomBulkActionProp,
SetEventsDeleted,
SetEventsLoading,
} from '../../../../../common/types';
import * as i18n from './translations';
import { useUpdateAlertsStatus } from './use_update_alerts';
import { useAppToasts } from '../../../hooks/use_app_toasts';
import { useStartTransaction } from '../../../lib/apm/use_start_transaction';
import { APM_USER_INTERACTIONS } from '../../../lib/apm/constants';
import type { AlertWorkflowStatus } from '../../../types';
import type { OnUpdateAlertStatusError, OnUpdateAlertStatusSuccess } from './types';
export const getUpdateAlertsQuery = (eventIds: Readonly<string[]>) => {
return { bool: { filter: { terms: { _id: eventIds } } } };
};
export interface BulkActionsProps {
eventIds: string[];
currentStatus?: AlertWorkflowStatus;
query?: string;
indexName: string;
setEventsLoading: SetEventsLoading;
setEventsDeleted: SetEventsDeleted;
showAlertStatusActions?: boolean;
onUpdateSuccess?: OnUpdateAlertStatusSuccess;
onUpdateFailure?: OnUpdateAlertStatusError;
customBulkActions?: CustomBulkActionProp[];
}
export const useBulkActionItems = ({
eventIds,
currentStatus,
@ -31,12 +50,12 @@ export const useBulkActionItems = ({
onUpdateFailure,
customBulkActions,
}: BulkActionsProps) => {
const { updateAlertStatus } = useUpdateAlertsStatus(true);
const { updateAlertStatus } = useUpdateAlertsStatus();
const { addSuccess, addError, addWarning } = useAppToasts();
const { startTransaction } = useStartTransaction();
const onAlertStatusUpdateSuccess = useCallback(
(updated: number, conflicts: number, newStatus: AlertStatus) => {
(updated: number, conflicts: number, newStatus: AlertWorkflowStatus) => {
if (conflicts > 0) {
// Partial failure
addWarning({
@ -52,7 +71,6 @@ export const useBulkActionItems = ({
case 'open':
title = i18n.OPENED_ALERT_SUCCESS_TOAST(updated);
break;
case 'in-progress':
case 'acknowledged':
title = i18n.ACKNOWLEDGED_ALERT_SUCCESS_TOAST(updated);
}
@ -66,7 +84,7 @@ export const useBulkActionItems = ({
);
const onAlertStatusUpdateFailure = useCallback(
(newStatus: AlertStatus, error: Error) => {
(newStatus: AlertWorkflowStatus, error: Error) => {
let title: string;
switch (newStatus) {
case 'closed':
@ -75,7 +93,6 @@ export const useBulkActionItems = ({
case 'open':
title = i18n.OPENED_ALERT_FAILED_TOAST;
break;
case 'in-progress':
case 'acknowledged':
title = i18n.ACKNOWLEDGED_ALERT_FAILED_TOAST;
}
@ -88,7 +105,7 @@ export const useBulkActionItems = ({
);
const onClickUpdate = useCallback(
async (status: AlertStatus) => {
async (status: AlertWorkflowStatus) => {
if (query) {
startTransaction({ name: APM_USER_INTERACTIONS.BULK_QUERY_STATUS_UPDATE });
} else if (eventIds.length > 1) {
@ -141,7 +158,7 @@ export const useBulkActionItems = ({
<EuiContextMenuItem
key="open"
data-test-subj="open-alert-status"
onClick={() => onClickUpdate(FILTER_OPEN)}
onClick={() => onClickUpdate(FILTER_OPEN as AlertWorkflowStatus)}
>
{i18n.BULK_ACTION_OPEN_SELECTED}
</EuiContextMenuItem>
@ -152,7 +169,7 @@ export const useBulkActionItems = ({
<EuiContextMenuItem
key="acknowledge"
data-test-subj="acknowledged-alert-status"
onClick={() => onClickUpdate(FILTER_ACKNOWLEDGED)}
onClick={() => onClickUpdate(FILTER_ACKNOWLEDGED as AlertWorkflowStatus)}
>
{i18n.BULK_ACTION_ACKNOWLEDGED_SELECTED}
</EuiContextMenuItem>
@ -163,7 +180,7 @@ export const useBulkActionItems = ({
<EuiContextMenuItem
key="close"
data-test-subj="close-alert-status"
onClick={() => onClickUpdate(FILTER_CLOSED)}
onClick={() => onClickUpdate(FILTER_CLOSED as AlertWorkflowStatus)}
>
{i18n.BULK_ACTION_CLOSE_SELECTED}
</EuiContextMenuItem>

View file

@ -6,14 +6,11 @@
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { CoreStart } from '@kbn/core/public';
import type { CoreStart } from '@kbn/core/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import type { AlertStatus } from '../../common/types';
import {
DETECTION_ENGINE_SIGNALS_STATUS_URL,
RAC_ALERTS_BULK_UPDATE_URL,
} from '../../common/constants';
import { DETECTION_ENGINE_SIGNALS_STATUS_URL } from '../../../../../common/constants';
import type { AlertWorkflowStatus } from '../../../types';
/**
* Update alert status by query
@ -27,11 +24,9 @@ import {
*
* @throws An error if response is not OK
*/
export const useUpdateAlertsStatus = (
useDetectionEngine: boolean = false
): {
export const useUpdateAlertsStatus = (): {
updateAlertStatus: (params: {
status: AlertStatus;
status: AlertWorkflowStatus;
index: string;
query: object;
}) => Promise<estypes.UpdateByQueryResponse>;
@ -39,18 +34,10 @@ export const useUpdateAlertsStatus = (
const { http } = useKibana<CoreStart>().services;
return {
updateAlertStatus: async ({ status, index, query }) => {
if (useDetectionEngine) {
return http.fetch<estypes.UpdateByQueryResponse>(DETECTION_ENGINE_SIGNALS_STATUS_URL, {
method: 'POST',
body: JSON.stringify({ status, query }),
});
} else {
const response = await http.post<estypes.UpdateByQueryResponse>(
RAC_ALERTS_BULK_UPDATE_URL,
{ body: JSON.stringify({ index, status, query }) }
);
return response;
}
return http.fetch<estypes.UpdateByQueryResponse>(DETECTION_ENGINE_SIGNALS_STATUS_URL, {
method: 'POST',
body: JSON.stringify({ status, query }),
});
},
};
};

Some files were not shown because too many files have changed in this diff Show more